Skip to content
Merged
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
9 changes: 9 additions & 0 deletions impit-node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,14 @@ console.log(response.headers);
console.log(await response.text());
// console.log(await response.json());
// ...

// Override redirect behavior per request (default: follows instance-level setting)
const manualResponse = await impit.fetch("https://example.com/login", {
redirect: "manual", // "follow" | "manual" | "error"
});

if (manualResponse.status === 302) {
console.log("Redirect to:", manualResponse.headers.get("location"));
}
```

12 changes: 12 additions & 0 deletions impit-node/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,4 +399,16 @@ export interface RequestInit {
forceHttp3?: boolean
/** Abort signal to cancel the request. */
signal?: AbortSignal
/**
* The redirect mode to use for this request.
*
* - `'follow'` (default): Follow redirects automatically.
* - `'manual'`: Do not follow redirects; return the 3xx response as-is.
* - `'error'`: Throw a `TypeError` if the response is a redirect.
*
* When set, this overrides the instance-level {@link ImpitOptions.followRedirects} option for this request.
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/RequestInit#redirect | Fetch API `redirect` option}
*/
redirect?: 'follow' | 'manual' | 'error'
}
49 changes: 34 additions & 15 deletions impit-node/index.wrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ async function parseFetchOptions(resource, init) {
body: resource.body,
...init, // init overrides Request fields
};
// Extract redirect from Request only if not already set by init.
// Request.redirect defaults to 'follow', which is indistinguishable
// from an explicit 'follow', so we only use it when non-default to
// avoid silently overriding instance-level followRedirects.
if (!('redirect' in options) && resource.redirect !== 'follow') {
options.redirect = resource.redirect;
}
} else if (resource.toString) {
url = resource.toString();
} else {
Expand All @@ -79,6 +86,7 @@ async function parseFetchOptions(resource, init) {
timeout: options.timeout,
forceHttp3: options.forceHttp3,
signal: options.signal,
redirect: options.redirect,
};
}

Expand Down Expand Up @@ -147,7 +155,7 @@ class Impit extends native.Impit {
}

async fetch(resource, init) {
const { url: initialUrl, signal, ...options } = await parseFetchOptions(resource, init);
const { url: initialUrl, signal, redirect, ...options } = await parseFetchOptions(resource, init);

// Check immediately if already aborted (before creating any promises)
signal?.throwIfAborted();
Expand All @@ -159,7 +167,7 @@ class Impit extends native.Impit {
});

try {
return await this.#fetchWithRedirectHandling(initialUrl, options, signal, waitForAbort);
return await this.#fetchWithRedirectHandling(initialUrl, options, signal, waitForAbort, redirect);
} catch (err) {
rethrowNativeError(err);
} finally {
Expand All @@ -173,12 +181,17 @@ class Impit extends native.Impit {
* @param {object} options
* @param {AbortSignal} signal
* @param {Promise} waitForAbort
* @param {'follow' | 'manual' | 'error'} [redirect] Per-request redirect mode override
*/
async #fetchWithRedirectHandling(initialUrl, options, signal, waitForAbort) {
async #fetchWithRedirectHandling(initialUrl, options, signal, waitForAbort, redirect) {
let url = initialUrl;
let method = options.method || 'GET';
let redirectCount = 0;
const maxRedirects = this.#maxRedirects;
const followRedirects = redirect
? redirect === 'follow'
: this.#followRedirects;
const errorOnRedirect = redirect === 'error';

while (true) {
signal?.throwIfAborted();
Expand Down Expand Up @@ -211,22 +224,28 @@ class Impit extends native.Impit {
await this.#setCookies(responseHeaders, url);
}

if (this.#followRedirects && isRedirectStatus(originalResponse.status)) {
const location = responseHeaders.get('location');

if (!location) {
return this.#wrapResponse(originalResponse, signal);
if (isRedirectStatus(originalResponse.status)) {
if (errorOnRedirect) {
throw new TypeError(`URI requested responds with a redirect, redirect mode is set to 'error': ${url}`);
}

redirectCount++;
if (redirectCount > maxRedirects) {
throw new Error(`Maximum redirect limit (${maxRedirects}) exceeded`);
}
if (followRedirects) {
const location = responseHeaders.get('location');

url = new URL(location, url).toString();
method = shouldRewriteRedirectToGet(originalResponse.status, method) ? 'GET' : method;
if (!location) {
return this.#wrapResponse(originalResponse, signal);
}

continue;
redirectCount++;
if (redirectCount > maxRedirects) {
throw new Error(`Maximum redirect limit (${maxRedirects}) exceeded`);
}

url = new URL(location, url).toString();
method = shouldRewriteRedirectToGet(originalResponse.status, method) ? 'GET' : method;

continue;
}
}

return this.#wrapResponse(originalResponse, signal);
Expand Down
11 changes: 11 additions & 0 deletions impit-node/src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,15 @@ pub struct RequestInit {
/// Abort signal to cancel the request.
#[napi(ts_type = "AbortSignal")]
pub signal: Option<()>, // This value is consumed in the JS wrapper and is not passed through to the Rust layer.
/// The redirect mode to use for this request.
///
/// - `'follow'` (default): Follow redirects automatically.
/// - `'manual'`: Do not follow redirects; return the 3xx response as-is.
/// - `'error'`: Throw a `TypeError` if the response is a redirect.
///
/// When set, this overrides the instance-level {@link ImpitOptions.followRedirects} option for this request.
///
/// @see {@link https://developer.mozilla.org/en-US/docs/Web/API/RequestInit#redirect | Fetch API `redirect` option}
#[napi(ts_type = "'follow' | 'manual' | 'error'")]
pub redirect: Option<()>, // This value is consumed in the JS wrapper and is not passed through to the Rust layer.
}
128 changes: 104 additions & 24 deletions impit-node/test/basics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,42 +623,122 @@ describe.each([
});
});

// Skipping because of issues with redirected Standby requests on Apify
describe.skip('Redirects', () => {
test('redirects work by default', async (t) => {
const response = await impit.fetch(
getHttpBinUrl('/absolute-redirect/1'),
);
describe('Redirects', () => {
test('follows redirects by default', async () => {
const response = await impit.fetch('http://localhost:3001/redirect/1');

t.expect(response.status).toBe(200);
t.expect(response.url).toBe(getHttpBinUrl('/get', true));
expect(response.status).toBe(200);
expect(response.url).toBe('http://localhost:3001/get');
});

test('disabling redirects', async (t) => {
const impit = new Impit({
followRedirects: false
test('instance-level followRedirects: false disables redirects', async () => {
const noRedirect = new Impit({ followRedirects: false });
const response = await noRedirect.fetch('http://localhost:3001/redirect/1');

expect(response.status).toBe(302);
expect(response.headers.get('location')).toBe('/get');
});

test('instance-level maxRedirects limits redirect chain', async () => {
const limited = new Impit({ maxRedirects: 1 });

await expect(
limited.fetch('http://localhost:3001/redirect/2'),
).rejects.toThrow('Maximum redirect limit (1) exceeded');
});

test('per-request redirect: "manual" returns 3xx response', async () => {
const response = await impit.fetch('http://localhost:3001/redirect/1', {
redirect: 'manual',
});

const response = await impit.fetch(
getHttpBinUrl('/absolute-redirect/1'),
);
expect(response.status).toBe(302);
expect(response.headers.get('location')).toBe('/get');
});

t.expect(response.status).toBe(302);
t.expect(response.headers.get('location')).toBe(getHttpBinUrl('/get', false));
t.expect(response.url).toBe(getHttpBinUrl('/absolute-redirect/1', true));
test('per-request redirect: "error" throws TypeError on redirect', async () => {
await expect(
impit.fetch('http://localhost:3001/redirect/1', { redirect: 'error' }),
).rejects.toThrow(TypeError);
});

test('limiting redirects', async (t) => {
const impit = new Impit({
followRedirects: true,
maxRedirects: 1
test('per-request redirect: "follow" follows redirects', async () => {
const noRedirect = new Impit({ followRedirects: false });
const response = await noRedirect.fetch('http://localhost:3001/redirect/1', {
redirect: 'follow',
});

expect(response.status).toBe(200);
});

test('per-request redirect: "manual" overrides instance followRedirects: true', async () => {
const response = await impit.fetch('http://localhost:3001/redirect/1', {
redirect: 'manual',
});

expect(response.status).toBe(302);
});

test('redirect via Request object', async () => {
const request = new Request('http://localhost:3001/redirect/1', {
redirect: 'manual',
});
const response = await impit.fetch(request);

const response = impit.fetch(
getHttpBinUrl('/absolute-redirect/2'),
expect(response.status).toBe(302);
});

test('bare Request does not override instance followRedirects: false', async () => {
const noRedirect = new Impit({ followRedirects: false });
const request = new Request('http://localhost:3001/redirect/1');
const response = await noRedirect.fetch(request);

expect(response.status).toBe(302);
});

test('init overrides Request.redirect', async () => {
const request = new Request('http://localhost:3001/redirect/1', {
redirect: 'manual',
});
const response = await impit.fetch(request, { redirect: 'follow' });

expect(response.status).toBe(200);
});

test('redirect: "error" does not throw on non-redirect response', async () => {
const response = await impit.fetch('http://localhost:3001/get', {
redirect: 'error',
});

expect(response.status).toBe(200);
});

test('redirect: "follow" still respects instance maxRedirects', async () => {
const limited = new Impit({ followRedirects: false, maxRedirects: 1 });

await expect(
limited.fetch('http://localhost:3001/redirect/2', { redirect: 'follow' }),
).rejects.toThrow('Maximum redirect limit (1) exceeded');
});

test('redirect: "manual" with 301 status code', async () => {
const response = await impit.fetch(
'http://localhost:3001/redirect-to?url=/get&status_code=301',
{ redirect: 'manual' },
);

expect(response.status).toBe(301);
expect(response.headers.get('location')).toBe('/get');
});

test('redirect: "manual" with 307 status code', async () => {
const response = await impit.fetch(
'http://localhost:3001/redirect-to?url=/get&status_code=307',
{ redirect: 'manual' },
);

await t.expect(response).rejects.toThrowError('Too many redirects occurred. Maximum allowed');
expect(response.status).toBe(307);
expect(response.headers.get('location')).toBe('/get');
});
})
});
19 changes: 19 additions & 0 deletions impit-node/test/mock.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,25 @@ export async function runServer(port: number): Promise<Server> {
}
});

app.get('/redirect/:n', (req, res) => {
const n = parseInt(req.params.n, 10);
if (n > 1) {
res.redirect(302, `/redirect/${n - 1}`);
} else {
res.redirect(302, '/get');
}
});

app.get('/redirect-to', (req, res) => {
const url = req.query.url as string;
const statusCode = parseInt(req.query.status_code as string || '302', 10);
res.redirect(statusCode, url);
});

app.get('/get', (req, res) => {
res.json({ url: `http://localhost:${port}/get` });
});

app.get('/cookies/delete', (req, res) => {
for (const name of Object.keys(req.query)) {
res.clearCookie(name);
Expand Down
Loading