Skip to content

Commit f7aab6a

Browse files
committed
Fix fetch-proxy request method forwarding
Also, forward additional properties from existing request objects.
1 parent 71ba59f commit f7aab6a

File tree

3 files changed

+228
-52
lines changed

3 files changed

+228
-52
lines changed

packages/fetch-proxy/CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ This is the changelog for [`fetch-proxy`](https://github.com/remix-run/remix/tre
55
## HEAD
66

77
- Renamed package from `@mjackson/fetch-proxy` to `@remix-run/fetch-proxy`
8+
- FIX: A regression that stopped forwarding the method from an exising request object
9+
- Forward additional properties from existing request objects passed to the proxy, including:
10+
- cache
11+
- credentials
12+
- integrity
13+
- keepalive
14+
- mode
15+
- redirect
16+
- referrer
17+
- referrerPolicy
18+
- signal
819

920
## v0.4.0 (2025-07-11)
1021

packages/fetch-proxy/src/lib/fetch-proxy.test.ts

Lines changed: 206 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,73 +4,67 @@ import { describe, it } from 'node:test';
44
import { type FetchProxyOptions, createFetchProxy } from './fetch-proxy.ts';
55

66
async function testProxy(
7-
input: URL | RequestInfo,
8-
init: RequestInit | undefined,
7+
request: Request,
98
target: string | URL,
109
options?: FetchProxyOptions,
1110
): Promise<{ request: Request; response: Response }> {
12-
let request: Request;
11+
let capturedRequest: Request;
1312
let proxy = createFetchProxy(target, {
1413
...options,
1514
fetch(input, init) {
16-
request = new Request(input, init);
15+
capturedRequest = new Request(input, init);
1716
return options?.fetch?.(input, init) ?? Promise.resolve(new Response());
1817
},
1918
});
2019

21-
let response = await proxy(input, init);
20+
let response = await proxy(request);
2221

23-
assert.ok(request!);
22+
assert.ok(capturedRequest!);
2423

25-
return { request, response };
24+
return { request: capturedRequest, response };
2625
}
2726

2827
describe('fetch proxy', () => {
2928
it('appends the request URL pathname + search to the target URL', async () => {
3029
let { request: request1 } = await testProxy(
31-
'http://shopify.com',
32-
undefined,
33-
'https://remix.run:3000/rsc',
30+
new Request('http://shopify.com'),
31+
'https://remix.run:3000/dest',
3432
);
3533

36-
assert.equal(request1.url, 'https://remix.run:3000/rsc');
34+
assert.equal(request1.url, 'https://remix.run:3000/dest');
3735

3836
let { request: request2 } = await testProxy(
39-
'http://shopify.com/?q=remix',
40-
undefined,
41-
'https://remix.run:3000/rsc',
37+
new Request('http://shopify.com/?q=remix'),
38+
'https://remix.run:3000/dest',
4239
);
4340

44-
assert.equal(request2.url, 'https://remix.run:3000/rsc?q=remix');
41+
assert.equal(request2.url, 'https://remix.run:3000/dest?q=remix');
4542

4643
let { request: request3 } = await testProxy(
47-
'http://shopify.com/search?q=remix',
48-
undefined,
44+
new Request('http://shopify.com/search?q=remix'),
4945
'https://remix.run:3000/',
5046
);
5147

5248
assert.equal(request3.url, 'https://remix.run:3000/search?q=remix');
5349

5450
let { request: request4 } = await testProxy(
55-
'http://shopify.com/search?q=remix',
56-
undefined,
57-
'https://remix.run:3000/rsc',
51+
new Request('http://shopify.com/search?q=remix'),
52+
'https://remix.run:3000/dest',
5853
);
5954

60-
assert.equal(request4.url, 'https://remix.run:3000/rsc/search?q=remix');
55+
assert.equal(request4.url, 'https://remix.run:3000/dest/search?q=remix');
6156
});
6257

6358
it('forwards request method, headers, and body', async () => {
6459
let { request } = await testProxy(
65-
'http://shopify.com/search?q=remix',
66-
{
60+
new Request('http://shopify.com/search?q=remix', {
6761
method: 'POST',
6862
headers: {
6963
'Content-Type': 'text/plain',
7064
},
7165
body: 'hello',
72-
},
73-
'https://remix.run:3000/rsc',
66+
}),
67+
'https://remix.run:3000/dest',
7468
);
7569

7670
assert.equal(request.method, 'POST');
@@ -80,23 +74,36 @@ describe('fetch proxy', () => {
8074

8175
it('forwards an empty request body', async () => {
8276
let { request } = await testProxy(
83-
'http://shopify.com/search?q=remix',
84-
{
77+
new Request('http://shopify.com/search?q=remix', {
8578
method: 'POST',
86-
},
87-
'https://remix.run:3000/rsc',
79+
}),
80+
'https://remix.run:3000/dest',
8881
);
8982

9083
assert.equal(request.method, 'POST');
9184
assert.equal(request.headers.get('Content-Type'), null);
9285
assert.equal(await request.text(), '');
9386
});
9487

88+
it('forwards various HTTP methods correctly', async () => {
89+
let methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD'];
90+
91+
for (let method of methods) {
92+
let { request } = await testProxy(
93+
new Request('http://shopify.com/api/resource', {
94+
method,
95+
}),
96+
'https://remix.run:3000/backend',
97+
);
98+
99+
assert.equal(request.method, method, `Method ${method} should be forwarded correctly`);
100+
}
101+
});
102+
95103
it('does not append X-Forwarded-Proto and X-Forwarded-Host headers by default', async () => {
96104
let { request } = await testProxy(
97-
'http://shopify.com:8080/search?q=remix',
98-
undefined,
99-
'https://remix.run:3000/rsc',
105+
new Request('http://shopify.com:8080/search?q=remix'),
106+
'https://remix.run:3000/dest',
100107
);
101108

102109
assert.equal(request.headers.get('X-Forwarded-Proto'), null);
@@ -105,9 +112,8 @@ describe('fetch proxy', () => {
105112

106113
it('appends X-Forwarded-Proto and X-Forwarded-Host headers when desired', async () => {
107114
let { request } = await testProxy(
108-
'http://shopify.com:8080/search?q=remix',
109-
undefined,
110-
'https://remix.run:3000/rsc',
115+
new Request('http://shopify.com:8080/search?q=remix'),
116+
'https://remix.run:3000/dest',
111117
{
112118
xForwardedHeaders: true,
113119
},
@@ -119,31 +125,31 @@ describe('fetch proxy', () => {
119125

120126
it('forwards additional request init options', async () => {
121127
let { request } = await testProxy(
122-
'http://shopify.com/search?q=remix',
123-
{
128+
new Request('http://shopify.com/search?q=remix', {
129+
method: 'DELETE',
124130
cache: 'no-cache',
125131
credentials: 'include',
126132
redirect: 'manual',
127-
},
128-
'https://remix.run:3000/rsc',
133+
}),
134+
'https://remix.run:3000/dest',
129135
);
130136

137+
assert.equal(request.method, 'DELETE');
131138
assert.equal(request.cache, 'no-cache');
132139
assert.equal(request.credentials, 'include');
133140
assert.equal(request.redirect, 'manual');
134141
});
135142

136143
it('rewrites cookie domain and path', async () => {
137144
let { response } = await testProxy(
138-
'http://shopify.com/search?q=remix',
139-
undefined,
140-
'https://remix.run:3000/rsc',
145+
new Request('http://shopify.com/search?q=remix'),
146+
'https://remix.run:3000/dest',
141147
{
142148
async fetch() {
143149
return new Response(null, {
144150
headers: [
145-
['Set-Cookie', 'name=value; Domain=remix.run:3000; Path=/rsc/search'],
146-
['Set-Cookie', 'name2=value2; Domain=remix.run:3000; Path=/rsc'],
151+
['Set-Cookie', 'name=value; Domain=remix.run:3000; Path=/dest/search'],
152+
['Set-Cookie', 'name2=value2; Domain=remix.run:3000; Path=/dest'],
147153
],
148154
});
149155
},
@@ -159,17 +165,16 @@ describe('fetch proxy', () => {
159165

160166
it('does not rewrite cookie domain and path when opting-out', async () => {
161167
let { response } = await testProxy(
162-
'http://shopify.com/?q=remix',
163-
undefined,
164-
'https://remix.run:3000/rsc',
168+
new Request('http://shopify.com/?q=remix'),
169+
'https://remix.run:3000/dest',
165170
{
166171
rewriteCookieDomain: false,
167172
rewriteCookiePath: false,
168173
async fetch() {
169174
return new Response(null, {
170175
headers: [
171-
['Set-Cookie', 'name=value; Domain=remix.run:3000; Path=/rsc/search'],
172-
['Set-Cookie', 'name2=value2; Domain=remix.run:3000; Path=/rsc'],
176+
['Set-Cookie', 'name=value; Domain=remix.run:3000; Path=/dest/search'],
177+
['Set-Cookie', 'name2=value2; Domain=remix.run:3000; Path=/dest'],
173178
],
174179
});
175180
},
@@ -179,7 +184,157 @@ describe('fetch proxy', () => {
179184
let setCookie = response.headers.getSetCookie();
180185
assert.ok(setCookie);
181186
assert.equal(setCookie.length, 2);
182-
assert.equal(setCookie[0], 'name=value; Domain=remix.run:3000; Path=/rsc/search');
183-
assert.equal(setCookie[1], 'name2=value2; Domain=remix.run:3000; Path=/rsc');
187+
assert.equal(setCookie[0], 'name=value; Domain=remix.run:3000; Path=/dest/search');
188+
assert.equal(setCookie[1], 'name2=value2; Domain=remix.run:3000; Path=/dest');
189+
});
190+
191+
it('preserves all request properties when using proxy(request)', async () => {
192+
let capturedRequest: Request;
193+
let proxy = createFetchProxy('https://remix.run:3000/dest', {
194+
fetch(input, init) {
195+
capturedRequest = new Request(input, init);
196+
return Promise.resolve(new Response());
197+
},
198+
});
199+
200+
let originalRequest = new Request('http://shopify.com/api/resource', {
201+
method: 'PUT',
202+
cache: 'no-store',
203+
credentials: 'omit',
204+
integrity: 'sha256-BpfBw7ivV8q2jLiT13fxDYAe2tJllusRSZ273h2nFSE=',
205+
keepalive: true,
206+
mode: 'cors',
207+
redirect: 'error',
208+
referrer: 'http://example.com',
209+
referrerPolicy: 'no-referrer',
210+
});
211+
212+
await proxy(originalRequest);
213+
214+
assert.ok(capturedRequest!);
215+
assert.equal(capturedRequest.method, 'PUT');
216+
assert.equal(capturedRequest.cache, 'no-store');
217+
assert.equal(capturedRequest.credentials, 'omit');
218+
assert.equal(capturedRequest.integrity, 'sha256-BpfBw7ivV8q2jLiT13fxDYAe2tJllusRSZ273h2nFSE=');
219+
assert.equal(capturedRequest.keepalive, true);
220+
assert.equal(capturedRequest.mode, 'cors');
221+
assert.equal(capturedRequest.redirect, 'error');
222+
assert.equal(capturedRequest.referrer, 'http://example.com/');
223+
assert.equal(capturedRequest.referrerPolicy, 'no-referrer');
224+
});
225+
226+
it('allows init to override request properties', async () => {
227+
let capturedRequest: Request;
228+
let proxy = createFetchProxy('https://remix.run:3000/dest', {
229+
fetch(input, init) {
230+
capturedRequest = new Request(input, init);
231+
return Promise.resolve(new Response());
232+
},
233+
});
234+
235+
let originalRequest = new Request('http://shopify.com/api/resource', {
236+
method: 'PUT',
237+
cache: 'no-store',
238+
credentials: 'omit',
239+
});
240+
241+
await proxy(originalRequest, {
242+
method: 'POST',
243+
cache: 'default',
244+
credentials: 'include',
245+
});
246+
247+
assert.ok(capturedRequest!);
248+
assert.equal(capturedRequest.method, 'POST');
249+
assert.equal(capturedRequest.cache, 'default');
250+
assert.equal(capturedRequest.credentials, 'include');
251+
});
252+
});
253+
254+
describe('fetch proxy (double-arg style)', () => {
255+
it('works with proxy(url, init) style', async () => {
256+
let capturedRequest: Request;
257+
let proxy = createFetchProxy('https://remix.run:3000/dest', {
258+
fetch(input, init) {
259+
capturedRequest = new Request(input, init);
260+
return Promise.resolve(new Response());
261+
},
262+
});
263+
264+
await proxy('http://shopify.com/api/resource', {
265+
method: 'PATCH',
266+
cache: 'reload',
267+
credentials: 'same-origin',
268+
headers: {
269+
'X-Custom': 'value',
270+
},
271+
});
272+
273+
assert.ok(capturedRequest!);
274+
assert.equal(capturedRequest.method, 'PATCH');
275+
assert.equal(capturedRequest.cache, 'reload');
276+
assert.equal(capturedRequest.credentials, 'same-origin');
277+
assert.equal(capturedRequest.headers.get('X-Custom'), 'value');
278+
assert.equal(capturedRequest.url, 'https://remix.run:3000/dest/api/resource');
279+
});
280+
281+
it('handles proxy(url) with defaults', async () => {
282+
let capturedRequest: Request;
283+
let proxy = createFetchProxy('https://remix.run:3000/dest', {
284+
fetch(input, init) {
285+
capturedRequest = new Request(input, init);
286+
return Promise.resolve(new Response());
287+
},
288+
});
289+
290+
await proxy('http://shopify.com/api/resource');
291+
292+
assert.ok(capturedRequest!);
293+
assert.equal(capturedRequest.method, 'GET');
294+
assert.equal(capturedRequest.url, 'https://remix.run:3000/dest/api/resource');
295+
});
296+
297+
it('forwards headers correctly with proxy(url, init)', async () => {
298+
let capturedRequest: Request;
299+
let proxy = createFetchProxy('https://remix.run:3000/dest', {
300+
fetch(input, init) {
301+
capturedRequest = new Request(input, init);
302+
return Promise.resolve(new Response());
303+
},
304+
});
305+
306+
await proxy('http://shopify.com/api/resource', {
307+
headers: {
308+
Authorization: 'Bearer token123',
309+
'Content-Type': 'application/json',
310+
},
311+
});
312+
313+
assert.ok(capturedRequest!);
314+
assert.equal(capturedRequest.headers.get('Authorization'), 'Bearer token123');
315+
assert.equal(capturedRequest.headers.get('Content-Type'), 'application/json');
316+
});
317+
318+
it('handles body with proxy(url, init)', async () => {
319+
let capturedRequest: Request;
320+
let proxy = createFetchProxy('https://remix.run:3000/dest', {
321+
fetch(input, init) {
322+
capturedRequest = new Request(input, init);
323+
return Promise.resolve(new Response());
324+
},
325+
});
326+
327+
let body = JSON.stringify({ name: 'test', value: 123 });
328+
await proxy('http://shopify.com/api/resource', {
329+
method: 'POST',
330+
headers: {
331+
'Content-Type': 'application/json',
332+
},
333+
body,
334+
});
335+
336+
assert.ok(capturedRequest!);
337+
assert.equal(capturedRequest.method, 'POST');
338+
assert.equal(await capturedRequest.text(), body);
184339
});
185340
});

0 commit comments

Comments
 (0)