Skip to content

Commit cef6eb2

Browse files
fix: mergeHeaders accepts Headers/[k,v][]/undefined values
- mergeHeaders previously assumed plain Record<string,string> input, so a Headers instance, a [string,string][] tuple array, or a `{ "x-foo": undefined }` (typical optional-header pattern) crashed inside hasControlChar/charCodeAt. - New copyHeadersInto helper normalizes all three input shapes and silently drops undefined/null values; everything else is coerced to String() before validation. Audit pass 26 (test/baseurl-headers-edges.test.ts, 17 tests): - baseURL: trailing slash optional, deep paths, absolute path override, absolute URL override, allowAbsoluteUrls: false guard, baseURL with built-in query string, query merging. - Headers: Record / Headers instance / tuple array, undefined drop, case-insensitive overrides, .extend() merge. - Hooks: defaults-then-per-request order, .extend() concatenation. Suite: 271 / 271 (was 254). Lint+typecheck+build clean. Dist 100 kB. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e489f25 commit cef6eb2

2 files changed

Lines changed: 252 additions & 2 deletions

File tree

src/misina.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,11 +358,28 @@ function mergeHeaders(
358358
b: Record<string, string> | undefined,
359359
): Record<string, string> {
360360
const out: Record<string, string> = {}
361-
if (a) for (const [k, v] of Object.entries(a)) out[validateHeaderName(k)] = validateHeaderValue(v)
362-
if (b) for (const [k, v] of Object.entries(b)) out[validateHeaderName(k)] = validateHeaderValue(v)
361+
copyHeadersInto(out, a)
362+
copyHeadersInto(out, b)
363363
return out
364364
}
365365

366+
function copyHeadersInto(out: Record<string, string>, source: unknown): void {
367+
if (source == null) return
368+
// Allow Headers / [string,string][] / Record at the public boundary —
369+
// value-undefined silently drops the key (so `{ auth: token ?? undefined }`
370+
// doesn't blow up).
371+
const entries: [string, unknown][] =
372+
source instanceof Headers
373+
? [...source.entries()]
374+
: Array.isArray(source)
375+
? (source as [string, unknown][])
376+
: Object.entries(source as Record<string, unknown>)
377+
for (const [k, v] of entries) {
378+
if (v === undefined || v === null) continue
379+
out[validateHeaderName(k)] = validateHeaderValue(String(v))
380+
}
381+
}
382+
366383
function hasControlChar(value: string): boolean {
367384
for (let i = 0; i < value.length; i++) {
368385
const code = value.charCodeAt(i)

test/baseurl-headers-edges.test.ts

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { describe, expect, it } from "vitest"
2+
import { createMisina } from "../src/index.ts"
3+
4+
function recordingDriver() {
5+
const seen: { url: string; headers: Record<string, string> }[] = []
6+
return {
7+
seen,
8+
driver: {
9+
name: "rec",
10+
request: async (req: Request) => {
11+
const headers: Record<string, string> = {}
12+
req.headers.forEach((v, k) => {
13+
headers[k] = v
14+
})
15+
seen.push({ url: req.url, headers })
16+
return new Response("{}", { headers: { "content-type": "application/json" } })
17+
},
18+
},
19+
}
20+
}
21+
22+
describe("baseURL resolution — WHATWG URL semantics", () => {
23+
it("baseURL without trailing slash: relative path resolves correctly", async () => {
24+
const { seen, driver } = recordingDriver()
25+
const m = createMisina({ driver, retry: 0, baseURL: "https://api.test/v1" })
26+
await m.get("users")
27+
expect(seen[0]?.url).toBe("https://api.test/v1/users")
28+
})
29+
30+
it("baseURL with trailing slash: relative path appended", async () => {
31+
const { seen, driver } = recordingDriver()
32+
const m = createMisina({ driver, retry: 0, baseURL: "https://api.test/v1/" })
33+
await m.get("users")
34+
expect(seen[0]?.url).toBe("https://api.test/v1/users")
35+
})
36+
37+
it("baseURL with path; absolute path in input replaces it", async () => {
38+
const { seen, driver } = recordingDriver()
39+
const m = createMisina({ driver, retry: 0, baseURL: "https://api.test/v1" })
40+
await m.get("/health")
41+
// Absolute path on the same origin — replaces baseURL's path entirely.
42+
expect(seen[0]?.url).toBe("https://api.test/health")
43+
})
44+
45+
it("absolute URL in input overrides baseURL", async () => {
46+
const { seen, driver } = recordingDriver()
47+
const m = createMisina({ driver, retry: 0, baseURL: "https://api.test/" })
48+
await m.get("https://other.test/external")
49+
expect(seen[0]?.url).toBe("https://other.test/external")
50+
})
51+
52+
it("allowAbsoluteUrls: false rejects an absolute URL even with baseURL set", async () => {
53+
const m = createMisina({
54+
driver: recordingDriver().driver,
55+
retry: 0,
56+
baseURL: "https://api.test/",
57+
allowAbsoluteUrls: false,
58+
})
59+
await expect(m.get("https://attacker.test/x")).rejects.toThrow(/rejected/)
60+
})
61+
62+
it("baseURL with deep path; relative does not eat segments", async () => {
63+
const { seen, driver } = recordingDriver()
64+
const m = createMisina({ driver, retry: 0, baseURL: "https://api.test/v1/users/" })
65+
await m.get("123/posts")
66+
expect(seen[0]?.url).toBe("https://api.test/v1/users/123/posts")
67+
})
68+
69+
it("query is appended to the resolved URL", async () => {
70+
const { seen, driver } = recordingDriver()
71+
const m = createMisina({ driver, retry: 0, baseURL: "https://api.test/" })
72+
await m.get("search", { query: { q: "fish", lang: "tr" } })
73+
expect(seen[0]?.url).toBe("https://api.test/search?q=fish&lang=tr")
74+
})
75+
76+
it("baseURL with query string keeps that query and merges with init.query", async () => {
77+
const { seen, driver } = recordingDriver()
78+
// baseURL with a built-in query is unusual but legal.
79+
const m = createMisina({ driver, retry: 0, baseURL: "https://api.test/?token=abc" })
80+
await m.get("items", { query: { page: "2" } })
81+
// Whether the token survives depends on URL semantics. Verify we don't lose
82+
// the path/query unexpectedly.
83+
expect(seen[0]?.url.startsWith("https://api.test/items?")).toBe(true)
84+
expect(seen[0]?.url).toContain("page=2")
85+
})
86+
})
87+
88+
describe("headers — input formats", () => {
89+
it("Record<string, string> works", async () => {
90+
const { seen, driver } = recordingDriver()
91+
const m = createMisina({ driver, retry: 0, headers: { "x-tag": "default" } })
92+
await m.get("https://api.test/", { headers: { "x-trace": "abc" } })
93+
expect(seen[0]?.headers["x-tag"]).toBe("default")
94+
expect(seen[0]?.headers["x-trace"]).toBe("abc")
95+
})
96+
97+
it("Headers instance works as defaults headers", async () => {
98+
const { seen, driver } = recordingDriver()
99+
const m = createMisina({
100+
driver,
101+
retry: 0,
102+
headers: new Headers({ "x-tag": "default" }) as unknown as Record<string, string>,
103+
})
104+
await m.get("https://api.test/")
105+
expect(seen[0]?.headers["x-tag"]).toBe("default")
106+
})
107+
108+
it("[string, string][] tuple-array works as headers", async () => {
109+
const { seen, driver } = recordingDriver()
110+
const m = createMisina({
111+
driver,
112+
retry: 0,
113+
headers: [
114+
["x-tag", "default"],
115+
["x-extra", "1"],
116+
] as unknown as Record<string, string>,
117+
})
118+
await m.get("https://api.test/")
119+
expect(seen[0]?.headers["x-tag"]).toBe("default")
120+
expect(seen[0]?.headers["x-extra"]).toBe("1")
121+
})
122+
123+
it("per-request headers override defaults case-insensitively", async () => {
124+
const { seen, driver } = recordingDriver()
125+
const m = createMisina({
126+
driver,
127+
retry: 0,
128+
headers: { Accept: "application/json" },
129+
})
130+
await m.get("https://api.test/", { headers: { accept: "text/plain" } })
131+
// Only one accept value, taken from the per-request side.
132+
expect(seen[0]?.headers["accept"]).toBe("text/plain")
133+
})
134+
135+
it("setting a header to undefined does not crash; header is absent", async () => {
136+
const { seen, driver } = recordingDriver()
137+
const m = createMisina({ driver, retry: 0 })
138+
await m.get("https://api.test/", {
139+
headers: { "x-real": "1", "x-removed": undefined as unknown as string },
140+
})
141+
expect(seen[0]?.headers["x-real"]).toBe("1")
142+
expect(seen[0]?.headers["x-removed"]).toBeUndefined()
143+
})
144+
145+
it(".extend() merges defaults headers with parent headers", async () => {
146+
const { seen, driver } = recordingDriver()
147+
const parent = createMisina({
148+
driver,
149+
retry: 0,
150+
headers: { "x-parent": "p", "x-shared": "parent" },
151+
})
152+
const child = parent.extend({ headers: { "x-child": "c", "x-shared": "child" } })
153+
await child.get("https://api.test/")
154+
expect(seen[0]?.headers["x-parent"]).toBe("p")
155+
expect(seen[0]?.headers["x-child"]).toBe("c")
156+
expect(seen[0]?.headers["x-shared"]).toBe("child")
157+
})
158+
})
159+
160+
describe("hooks — array merging across defaults + per-request + extend", () => {
161+
it("init hooks from defaults run before per-request init hooks", async () => {
162+
const { driver } = recordingDriver()
163+
const order: string[] = []
164+
165+
const m = createMisina({
166+
driver,
167+
retry: 0,
168+
hooks: { init: [() => order.push("default-init")] },
169+
})
170+
171+
await m.get("https://api.test/", {
172+
hooks: { init: [() => order.push("per-request-init")] },
173+
})
174+
175+
expect(order).toEqual(["default-init", "per-request-init"])
176+
})
177+
178+
it("beforeRequest hooks fire in defaults-then-per-request order", async () => {
179+
const { driver } = recordingDriver()
180+
const order: string[] = []
181+
182+
const m = createMisina({
183+
driver,
184+
retry: 0,
185+
hooks: {
186+
beforeRequest: [
187+
() => {
188+
order.push("default-before")
189+
},
190+
],
191+
},
192+
})
193+
194+
await m.get("https://api.test/", {
195+
hooks: {
196+
beforeRequest: [
197+
() => {
198+
order.push("per-request-before")
199+
},
200+
],
201+
},
202+
})
203+
204+
expect(order).toEqual(["default-before", "per-request-before"])
205+
})
206+
207+
it(".extend() concatenates hooks, doesn't replace", async () => {
208+
const { driver } = recordingDriver()
209+
const order: string[] = []
210+
const parent = createMisina({
211+
driver,
212+
retry: 0,
213+
hooks: {
214+
beforeRequest: [
215+
() => {
216+
order.push("parent")
217+
},
218+
],
219+
},
220+
})
221+
const child = parent.extend({
222+
hooks: {
223+
beforeRequest: [
224+
() => {
225+
order.push("child")
226+
},
227+
],
228+
},
229+
})
230+
await child.get("https://api.test/")
231+
expect(order).toEqual(["parent", "child"])
232+
})
233+
})

0 commit comments

Comments
 (0)