From 516548b927196aca198ef9b86a0e447e2d837614 Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Sun, 22 Mar 2026 14:06:54 +0900 Subject: [PATCH 1/3] feat(node): add per-request `redirect` option to `RequestInit` Support standard Fetch API `redirect` option ('follow' | 'manual' | 'error') on individual requests, overriding the instance-level `followRedirects` setting. --- impit-node/README.md | 9 ++++ impit-node/dts-header.d.ts | 15 +++++++ impit-node/index.wrapper.js | 43 ++++++++++++------- impit-node/test/basics.test.ts | 76 +++++++++++++++++++++++----------- impit-node/test/mock.server.ts | 19 +++++++++ 5 files changed, 122 insertions(+), 40 deletions(-) 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/dts-header.d.ts b/impit-node/dts-header.d.ts index 6782d9b0..fda64a35 100644 --- a/impit-node/dts-header.d.ts +++ b/impit-node/dts-header.d.ts @@ -34,3 +34,18 @@ export declare class StreamConsumed extends StreamError {} export declare class ResponseNotRead extends StreamError {} export declare class RequestNotRead extends StreamError {} export declare class StreamClosed extends StreamError {} + +export interface RequestInit { + /** + * 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..4ec5b414 100644 --- a/impit-node/index.wrapper.js +++ b/impit-node/index.wrapper.js @@ -51,6 +51,7 @@ async function parseFetchOptions(resource, init) { method: resource.method, headers: resource.headers, body: resource.body, + redirect: resource.redirect, ...init, // init overrides Request fields }; } else if (resource.toString) { @@ -79,6 +80,7 @@ async function parseFetchOptions(resource, init) { timeout: options.timeout, forceHttp3: options.forceHttp3, signal: options.signal, + redirect: options.redirect, }; } @@ -147,7 +149,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 +161,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 +175,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 +218,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'); + + if (!location) { + return this.#wrapResponse(originalResponse, signal); + } - url = new URL(location, url).toString(); - method = shouldRewriteRedirectToGet(originalResponse.status, method) ? 'GET' : method; + redirectCount++; + if (redirectCount > maxRedirects) { + throw new Error(`Maximum redirect limit (${maxRedirects}) exceeded`); + } - continue; + url = new URL(location, url).toString(); + method = shouldRewriteRedirectToGet(originalResponse.status, method) ? 'GET' : method; + + continue; + } } return this.#wrapResponse(originalResponse, signal); diff --git a/impit-node/test/basics.test.ts b/impit-node/test/basics.test.ts index fbcb65ae..392137da 100644 --- a/impit-node/test/basics.test.ts +++ b/impit-node/test/basics.test.ts @@ -623,42 +623,68 @@ 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); }); - 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', }); - const response = impit.fetch( - getHttpBinUrl('/absolute-redirect/2'), - ); + 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); - await t.expect(response).rejects.toThrowError('Too many redirects occurred. Maximum allowed'); + expect(response.status).toBe(302); }); }) }); 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); From 4faaf3bb4b528a96945e3dfed8c651d76ba1487c Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Sun, 22 Mar 2026 14:14:59 +0900 Subject: [PATCH 2/3] fix: prevent bare Request.redirect default from overriding instance followRedirects Request.redirect defaults to 'follow' per Fetch API spec, making it impossible to distinguish from an explicit 'follow'. Only extract non-default redirect values from Request objects to avoid silently overriding instance-level followRedirects: false. Also adds tests for: error mode on non-redirect response, follow with maxRedirects, init overriding Request.redirect, 301/307 codes. --- impit-node/index.wrapper.js | 8 ++++- impit-node/test/basics.test.ts | 53 ++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/impit-node/index.wrapper.js b/impit-node/index.wrapper.js index 4ec5b414..b38f6ec0 100644 --- a/impit-node/index.wrapper.js +++ b/impit-node/index.wrapper.js @@ -51,9 +51,15 @@ async function parseFetchOptions(resource, init) { method: resource.method, headers: resource.headers, body: resource.body, - redirect: resource.redirect, ...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 { diff --git a/impit-node/test/basics.test.ts b/impit-node/test/basics.test.ts index 392137da..14a7f888 100644 --- a/impit-node/test/basics.test.ts +++ b/impit-node/test/basics.test.ts @@ -686,5 +686,58 @@ describe.each([ 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' }, + ); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe('/get'); + }); }) }); From 1932c7e7666d999c8eb37c02fd7d67ea80cba50c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Mon, 23 Mar 2026 12:43:40 +0100 Subject: [PATCH 3/3] refactor: clean up type definitions --- impit-node/dts-header.d.ts | 15 --------------- impit-node/index.d.ts | 12 ++++++++++++ impit-node/src/request.rs | 11 +++++++++++ impit-node/test/basics.test.ts | 1 + 4 files changed, 24 insertions(+), 15 deletions(-) diff --git a/impit-node/dts-header.d.ts b/impit-node/dts-header.d.ts index fda64a35..6782d9b0 100644 --- a/impit-node/dts-header.d.ts +++ b/impit-node/dts-header.d.ts @@ -34,18 +34,3 @@ export declare class StreamConsumed extends StreamError {} export declare class ResponseNotRead extends StreamError {} export declare class RequestNotRead extends StreamError {} export declare class StreamClosed extends StreamError {} - -export interface RequestInit { - /** - * 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.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/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 14a7f888..d7f30e92 100644 --- a/impit-node/test/basics.test.ts +++ b/impit-node/test/basics.test.ts @@ -628,6 +628,7 @@ describe.each([ const response = await impit.fetch('http://localhost:3001/redirect/1'); expect(response.status).toBe(200); + expect(response.url).toBe('http://localhost:3001/get'); }); test('instance-level followRedirects: false disables redirects', async () => {