Skip to content

Commit 7c63726

Browse files
authored
feat: implement fetchWithSelectiveRedirects for controlled redirect handling between apex/www and http/https versions of the same domain (#165)
1 parent cacdac4 commit 7c63726

File tree

4 files changed

+372
-6
lines changed

4 files changed

+372
-6
lines changed

lib/fetch.test.ts

Lines changed: 263 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
/* @vitest-environment node */
22
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3-
import { fetchWithTimeout, headThenGet } from "@/lib/fetch";
3+
import {
4+
fetchWithSelectiveRedirects,
5+
fetchWithTimeout,
6+
headThenGet,
7+
} from "@/lib/fetch";
48

59
const originalFetch = globalThis.fetch;
610

@@ -135,4 +139,262 @@ describe("lib/fetch", () => {
135139
expect(usedMethod).toBe("GET");
136140
expect(mock).toHaveBeenCalledTimes(2);
137141
});
142+
143+
describe("fetchWithSelectiveRedirects", () => {
144+
it("returns non-redirect responses immediately", async () => {
145+
const res = createResponse({ ok: true, status: 200 });
146+
globalThis.fetch = vi.fn(async () => res) as unknown as typeof fetch;
147+
148+
const out = await fetchWithSelectiveRedirects(
149+
"https://example.com",
150+
{},
151+
{ timeoutMs: 50 },
152+
);
153+
expect(out).toBe(res);
154+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
155+
});
156+
157+
it("follows redirect from http to https on same domain", async () => {
158+
const redirectRes = createResponse({
159+
status: 301,
160+
headers: new Headers({ location: "https://example.com/" }),
161+
});
162+
const finalRes = createResponse({ ok: true, status: 200 });
163+
164+
const mock = vi
165+
.fn()
166+
.mockResolvedValueOnce(redirectRes)
167+
.mockResolvedValueOnce(finalRes);
168+
globalThis.fetch = mock as unknown as typeof fetch;
169+
170+
const out = await fetchWithSelectiveRedirects(
171+
"http://example.com/",
172+
{},
173+
{ timeoutMs: 50 },
174+
);
175+
expect(out).toBe(finalRes);
176+
expect(mock).toHaveBeenCalledTimes(2);
177+
expect(mock).toHaveBeenNthCalledWith(
178+
1,
179+
"http://example.com/",
180+
expect.objectContaining({ redirect: "manual" }),
181+
);
182+
expect(mock).toHaveBeenNthCalledWith(
183+
2,
184+
"https://example.com/",
185+
expect.objectContaining({ redirect: "manual" }),
186+
);
187+
});
188+
189+
it("follows redirect from apex to www", async () => {
190+
const redirectRes = createResponse({
191+
status: 301,
192+
headers: new Headers({ location: "https://www.example.com/" }),
193+
});
194+
const finalRes = createResponse({ ok: true, status: 200 });
195+
196+
const mock = vi
197+
.fn()
198+
.mockResolvedValueOnce(redirectRes)
199+
.mockResolvedValueOnce(finalRes);
200+
globalThis.fetch = mock as unknown as typeof fetch;
201+
202+
const out = await fetchWithSelectiveRedirects(
203+
"https://example.com/",
204+
{},
205+
{ timeoutMs: 50 },
206+
);
207+
expect(out).toBe(finalRes);
208+
expect(mock).toHaveBeenCalledTimes(2);
209+
});
210+
211+
it("follows redirect from www to apex", async () => {
212+
const redirectRes = createResponse({
213+
status: 301,
214+
headers: new Headers({ location: "https://example.com/" }),
215+
});
216+
const finalRes = createResponse({ ok: true, status: 200 });
217+
218+
const mock = vi
219+
.fn()
220+
.mockResolvedValueOnce(redirectRes)
221+
.mockResolvedValueOnce(finalRes);
222+
globalThis.fetch = mock as unknown as typeof fetch;
223+
224+
const out = await fetchWithSelectiveRedirects(
225+
"https://www.example.com/",
226+
{},
227+
{ timeoutMs: 50 },
228+
);
229+
expect(out).toBe(finalRes);
230+
expect(mock).toHaveBeenCalledTimes(2);
231+
});
232+
233+
it("follows redirect from http://example.com to https://www.example.com", async () => {
234+
const redirectRes = createResponse({
235+
status: 301,
236+
headers: new Headers({ location: "https://www.example.com/" }),
237+
});
238+
const finalRes = createResponse({ ok: true, status: 200 });
239+
240+
const mock = vi
241+
.fn()
242+
.mockResolvedValueOnce(redirectRes)
243+
.mockResolvedValueOnce(finalRes);
244+
globalThis.fetch = mock as unknown as typeof fetch;
245+
246+
const out = await fetchWithSelectiveRedirects(
247+
"http://example.com/",
248+
{},
249+
{ timeoutMs: 50 },
250+
);
251+
expect(out).toBe(finalRes);
252+
expect(mock).toHaveBeenCalledTimes(2);
253+
});
254+
255+
it("does NOT follow redirect to different domain", async () => {
256+
const redirectRes = createResponse({
257+
status: 301,
258+
headers: new Headers({ location: "https://other.com/" }),
259+
});
260+
261+
globalThis.fetch = vi.fn(
262+
async () => redirectRes,
263+
) as unknown as typeof fetch;
264+
265+
const out = await fetchWithSelectiveRedirects(
266+
"https://example.com/",
267+
{},
268+
{ timeoutMs: 50 },
269+
);
270+
expect(out).toBe(redirectRes);
271+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
272+
});
273+
274+
it("does NOT follow redirect to different path", async () => {
275+
const redirectRes = createResponse({
276+
status: 301,
277+
headers: new Headers({ location: "https://example.com/other-path" }),
278+
});
279+
280+
globalThis.fetch = vi.fn(
281+
async () => redirectRes,
282+
) as unknown as typeof fetch;
283+
284+
const out = await fetchWithSelectiveRedirects(
285+
"https://example.com/",
286+
{},
287+
{ timeoutMs: 50 },
288+
);
289+
expect(out).toBe(redirectRes);
290+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
291+
});
292+
293+
it("does NOT follow redirect with query params", async () => {
294+
const redirectRes = createResponse({
295+
status: 301,
296+
headers: new Headers({
297+
location: "https://example.com/?utm_source=test",
298+
}),
299+
});
300+
301+
globalThis.fetch = vi.fn(
302+
async () => redirectRes,
303+
) as unknown as typeof fetch;
304+
305+
const out = await fetchWithSelectiveRedirects(
306+
"https://example.com/",
307+
{},
308+
{ timeoutMs: 50 },
309+
);
310+
expect(out).toBe(redirectRes);
311+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
312+
});
313+
314+
it("handles relative redirect URLs", async () => {
315+
const redirectRes = createResponse({
316+
status: 301,
317+
headers: new Headers({ location: "/" }),
318+
});
319+
320+
globalThis.fetch = vi.fn(
321+
async () => redirectRes,
322+
) as unknown as typeof fetch;
323+
324+
const out = await fetchWithSelectiveRedirects(
325+
"https://www.example.com/path",
326+
{},
327+
{ timeoutMs: 50 },
328+
);
329+
// Relative redirect to "/" changes the path (/path -> /), so should NOT be followed
330+
expect(out).toBe(redirectRes);
331+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
332+
});
333+
334+
it("throws on too many redirects", async () => {
335+
const redirectRes = createResponse({
336+
status: 301,
337+
headers: new Headers({ location: "https://www.example.com/" }),
338+
});
339+
340+
globalThis.fetch = vi.fn(
341+
async () => redirectRes,
342+
) as unknown as typeof fetch;
343+
344+
await expect(
345+
fetchWithSelectiveRedirects(
346+
"https://example.com/",
347+
{},
348+
{ timeoutMs: 50, maxRedirects: 2 },
349+
),
350+
).rejects.toThrow("Too many redirects");
351+
expect(globalThis.fetch).toHaveBeenCalledTimes(3); // initial + 2 redirects
352+
});
353+
354+
it("returns redirect response when no location header", async () => {
355+
const redirectRes = createResponse({
356+
status: 301,
357+
headers: new Headers(),
358+
});
359+
360+
globalThis.fetch = vi.fn(
361+
async () => redirectRes,
362+
) as unknown as typeof fetch;
363+
364+
const out = await fetchWithSelectiveRedirects(
365+
"https://example.com/",
366+
{},
367+
{ timeoutMs: 50 },
368+
);
369+
expect(out).toBe(redirectRes);
370+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
371+
});
372+
373+
it("handles redirect chain: http -> https -> www", async () => {
374+
const redirect1 = createResponse({
375+
status: 301,
376+
headers: new Headers({ location: "https://example.com/" }),
377+
});
378+
const redirect2 = createResponse({
379+
status: 301,
380+
headers: new Headers({ location: "https://www.example.com/" }),
381+
});
382+
const finalRes = createResponse({ ok: true, status: 200 });
383+
384+
const mock = vi
385+
.fn()
386+
.mockResolvedValueOnce(redirect1)
387+
.mockResolvedValueOnce(redirect2)
388+
.mockResolvedValueOnce(finalRes);
389+
globalThis.fetch = mock as unknown as typeof fetch;
390+
391+
const out = await fetchWithSelectiveRedirects(
392+
"http://example.com/",
393+
{},
394+
{ timeoutMs: 50 },
395+
);
396+
expect(out).toBe(finalRes);
397+
expect(mock).toHaveBeenCalledTimes(3);
398+
});
399+
});
138400
});

lib/fetch.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,105 @@ export async function headThenGet(
5252
);
5353
return { response: getRes, usedMethod: "GET" };
5454
}
55+
56+
/**
57+
* Checks if a redirect from one URL to another is allowed.
58+
* Only allows redirects between apex/www or http/https versions of the same domain.
59+
*/
60+
function isAllowedRedirect(fromUrl: string, toUrl: string): boolean {
61+
try {
62+
const from = new URL(fromUrl);
63+
const to = new URL(toUrl);
64+
65+
// Normalize hostnames by removing www. prefix for comparison
66+
const normalizeHost = (host: string) => host.replace(/^www\./i, "");
67+
const fromHost = normalizeHost(from.hostname);
68+
const toHost = normalizeHost(to.hostname);
69+
70+
// Must be the same registrable domain (after removing www)
71+
if (fromHost !== toHost) {
72+
return false;
73+
}
74+
75+
// Allow if path, search, and hash are the same (only scheme or www prefix changed)
76+
const isSchemeLike =
77+
from.pathname === to.pathname &&
78+
from.search === to.search &&
79+
from.hash === to.hash;
80+
if (isSchemeLike) {
81+
return true;
82+
}
83+
84+
return false;
85+
} catch {
86+
// If URL parsing fails, don't allow redirect
87+
return false;
88+
}
89+
}
90+
91+
/**
92+
* Fetch with manual redirect handling that only follows redirects between
93+
* apex/www or http/https versions of the same domain.
94+
*/
95+
export async function fetchWithSelectiveRedirects(
96+
input: RequestInfo | URL,
97+
init: RequestInit = {},
98+
opts: { timeoutMs?: number; maxRedirects?: number } = {},
99+
): Promise<Response> {
100+
const timeoutMs = opts.timeoutMs ?? 5000;
101+
const maxRedirects = opts.maxRedirects ?? 5;
102+
103+
let currentUrl =
104+
typeof input === "string" || input instanceof URL
105+
? input.toString()
106+
: input.url;
107+
let redirectCount = 0;
108+
109+
while (redirectCount <= maxRedirects) {
110+
const controller = new AbortController();
111+
const timer = setTimeout(() => controller.abort(), timeoutMs);
112+
113+
try {
114+
const response = await fetch(currentUrl, {
115+
...init,
116+
redirect: "manual",
117+
signal: controller.signal,
118+
});
119+
clearTimeout(timer);
120+
121+
// Check if this is a redirect response
122+
const isRedirect = response.status >= 300 && response.status < 400;
123+
if (!isRedirect) {
124+
return response;
125+
}
126+
127+
// Get the redirect location
128+
const location = response.headers.get("location");
129+
if (!location) {
130+
// No location header, return the redirect response as-is
131+
return response;
132+
}
133+
134+
// Resolve relative URLs
135+
const nextUrl = new URL(location, currentUrl).toString();
136+
137+
// Check if we should follow this redirect
138+
if (!isAllowedRedirect(currentUrl, nextUrl)) {
139+
// Return the redirect response without following
140+
return response;
141+
}
142+
143+
// Follow the redirect
144+
currentUrl = nextUrl;
145+
redirectCount++;
146+
} catch (err) {
147+
clearTimeout(timer);
148+
throw err;
149+
}
150+
}
151+
152+
// Max redirects exceeded
153+
throw new Error(
154+
`Too many redirects (${maxRedirects}) when fetching ${currentUrl}`,
155+
);
156+
}

0 commit comments

Comments
 (0)