diff --git a/impit-node/README.md b/impit-node/README.md index 1a2a8f2f..72997e5f 100644 --- a/impit-node/README.md +++ b/impit-node/README.md @@ -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")); +} ``` diff --git a/impit-node/index.d.ts b/impit-node/index.d.ts index 71e8aa39..66f53282 100644 --- a/impit-node/index.d.ts +++ b/impit-node/index.d.ts @@ -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' } diff --git a/impit-node/index.wrapper.js b/impit-node/index.wrapper.js index de94cb90..b38f6ec0 100644 --- a/impit-node/index.wrapper.js +++ b/impit-node/index.wrapper.js @@ -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 { @@ -79,6 +86,7 @@ async function parseFetchOptions(resource, init) { timeout: options.timeout, forceHttp3: options.forceHttp3, signal: options.signal, + redirect: options.redirect, }; } @@ -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(); @@ -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 { @@ -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(); @@ -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); diff --git a/impit-node/src/request.rs b/impit-node/src/request.rs index 2992f201..59657050 100644 --- a/impit-node/src/request.rs +++ b/impit-node/src/request.rs @@ -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. } diff --git a/impit-node/test/basics.test.ts b/impit-node/test/basics.test.ts index fbcb65ae..d7f30e92 100644 --- a/impit-node/test/basics.test.ts +++ b/impit-node/test/basics.test.ts @@ -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'); }); }) }); diff --git a/impit-node/test/mock.server.ts b/impit-node/test/mock.server.ts index 9faa17e7..94ae5d50 100644 --- a/impit-node/test/mock.server.ts +++ b/impit-node/test/mock.server.ts @@ -114,6 +114,25 @@ export async function runServer(port: number): Promise { } }); + 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);