Skip to content

Commit 0cf0f11

Browse files
authored
fix: better ip version detection when using skip wait port property in dev command (#7074)
1 parent f5bddb4 commit 0cf0f11

File tree

6 files changed

+113
-25
lines changed

6 files changed

+113
-25
lines changed

src/utils/proxy.ts

Lines changed: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import { readFile } from 'fs/promises'
44
import http, { ServerResponse } from 'http'
55
import https from 'https'
66
import { isIPv6 } from 'net'
7+
import { Socket } from 'node:net'
78
import { Readable } from 'node:stream'
89
import path from 'path'
910
import process from 'process'
1011
import { Duplex } from 'stream'
12+
import url from 'url'
1113
import util from 'util'
1214
import zlib from 'zlib'
1315

@@ -20,9 +22,9 @@ import httpProxy from 'http-proxy'
2022
import { createProxyMiddleware } from 'http-proxy-middleware'
2123
import { jwtDecode } from 'jwt-decode'
2224
import { locatePath } from 'locate-path'
25+
import throttle from 'lodash/throttle.js'
2326
import { Match } from 'netlify-redirector'
2427
import pFilter from 'p-filter'
25-
import throttle from 'lodash/throttle.js'
2628

2729
import { BaseCommand } from '../commands/index.js'
2830
import { $TSFixMe, NetlifyOptions } from '../commands/types.js'
@@ -515,18 +517,41 @@ const initializeProxy = async function ({
515517
}
516518
})
517519

518-
proxy.on('error', (_, req, res) => {
519-
// @ts-expect-error TS(2339) FIXME: Property 'writeHead' does not exist on type 'Socke... Remove this comment to see the full error message
520-
res.writeHead(500, {
521-
'Content-Type': 'text/plain',
522-
})
520+
proxy.on('error', (err, req, res, proxyUrl) => {
521+
// @ts-expect-error TS(2339) FIXME: Property 'proxyOptions' does not exist on type 'In... Remove this comment to see the full error message
522+
const options = req.proxyOptions
523+
524+
const isConRefused = 'code' in err && err.code === 'ECONNREFUSED'
525+
if (options?.detectTarget && !(res instanceof Socket) && isConRefused && proxyUrl) {
526+
// got econnrefused while detectTarget set to true -> try to switch between current ipVer and other (4 to 6 and vice versa)
527+
528+
// proxyUrl is parsed in http-proxy using url, parsing the same here. Difference between it and
529+
// URL that hostname not includes [] symbols when using url.parse
530+
// eslint-disable-next-line n/no-deprecated-api
531+
const targetUrl = typeof proxyUrl === 'string' ? url.parse(proxyUrl) : proxyUrl
532+
const isCurrentHost = targetUrl.hostname === options.targetHostname
533+
if (targetUrl.hostname && isCurrentHost) {
534+
const newHost = isIPv6(targetUrl.hostname) ? '127.0.0.1' : '::1'
535+
options.target = `http://${isIPv6(newHost) ? `[${newHost}]` : newHost}:${targetUrl.port}`
536+
options.targetHostname = newHost
537+
options.isChangingTarget = true
538+
return proxy.web(req, res, options)
539+
}
540+
}
541+
542+
if (res instanceof http.ServerResponse) {
543+
res.writeHead(500, {
544+
'Content-Type': 'text/plain',
545+
})
546+
}
523547

524548
const message = isEdgeFunctionsRequest(req)
525549
? 'There was an error with an Edge Function. Please check the terminal for more details.'
526550
: 'Could not proxy request.'
527551

528552
res.end(message)
529553
})
554+
530555
proxy.on('proxyReq', (proxyReq, req) => {
531556
const requestID = generateRequestID()
532557

@@ -548,6 +573,7 @@ const initializeProxy = async function ({
548573
proxyReq.write(req.originalBody)
549574
}
550575
})
576+
551577
proxy.on('proxyRes', (proxyRes, req, res) => {
552578
res.setHeader('server', 'Netlify')
553579

@@ -557,6 +583,23 @@ const initializeProxy = async function ({
557583
res.setHeader(NFRequestID, requestID)
558584
}
559585

586+
// @ts-expect-error TS(2339) FIXME: Property 'proxyOptions' does not exist on type 'In... Remove this comment to see the full error message
587+
const options = req.proxyOptions
588+
589+
if (options.isChangingTarget) {
590+
// got a response after switching the ipVer for host (and its not an error since we will be in on('error') handler) - let's remember this host now
591+
592+
// options are not exported in ts for the proxy:
593+
// @ts-expect-error TS(2339) FIXME: Property 'options' does not exist on type 'In...
594+
proxy.options.target.host = options.targetHostname
595+
596+
options.changeSettings?.({
597+
frameworkHost: options.targetHostname,
598+
detectFrameworkHost: false,
599+
})
600+
console.log(`${NETLIFYDEVLOG} Switched host to ${options.targetHostname}`)
601+
}
602+
560603
if (proxyRes.statusCode === 404 || proxyRes.statusCode === 403) {
561604
// If a request for `/path` has failed, we'll a few variations like
562605
// `/path/index.html` to mimic the CDN behavior.
@@ -571,8 +614,7 @@ const initializeProxy = async function ({
571614
// The request has failed but we might still have a matching redirect
572615
// rule (without `force`) that should kick in. This is how we mimic the
573616
// file shadowing behavior from the CDN.
574-
// @ts-expect-error TS(2339) FIXME: Property 'proxyOptions' does not exist on type 'In... Remove this comment to see the full error message
575-
if (req.proxyOptions && req.proxyOptions.match) {
617+
if (options && options.match) {
576618
return serveRedirect({
577619
// We don't want to match functions at this point because any redirects
578620
// to functions will have already been processed, so we don't supply a
@@ -582,18 +624,15 @@ const initializeProxy = async function ({
582624
res,
583625
proxy: handlers,
584626
imageProxy,
585-
// @ts-expect-error TS(2339) FIXME: Property 'proxyOptions' does not exist on type 'In... Remove this comment to see the full error message
586-
match: req.proxyOptions.match,
587-
// @ts-expect-error TS(2339) FIXME: Property 'proxyOptions' does not exist on type 'In... Remove this comment to see the full error message
588-
options: req.proxyOptions,
627+
match: options.match,
628+
options,
589629
siteInfo,
590630
env,
591631
})
592632
}
593633
}
594634

595-
// @ts-expect-error TS(2339) FIXME: Property 'proxyOptions' does not exist on type 'In... Remove this comment to see the full error message
596-
if (req.proxyOptions.staticFile && isRedirect({ status: proxyRes.statusCode }) && proxyRes.headers.location) {
635+
if (options.staticFile && isRedirect({ status: proxyRes.statusCode }) && proxyRes.headers.location) {
597636
req.url = proxyRes.headers.location
598637
return serveRedirect({
599638
// We don't want to match functions at this point because any redirects
@@ -605,8 +644,7 @@ const initializeProxy = async function ({
605644
proxy: handlers,
606645
imageProxy,
607646
match: null,
608-
// @ts-expect-error TS(2339) FIXME: Property 'proxyOptions' does not exist on type 'In... Remove this comment to see the full error message
609-
options: req.proxyOptions,
647+
options,
610648
siteInfo,
611649
env,
612650
})
@@ -634,8 +672,7 @@ const initializeProxy = async function ({
634672
// @ts-expect-error TS(2345) FIXME: Argument of type 'unknown' is not assignable to pa... Remove this comment to see the full error message
635673
res.setHeader(key, val)
636674
})
637-
// @ts-expect-error TS(2339) FIXME: Property 'proxyOptions' does not exist on type 'In... Remove this comment to see the full error message
638-
res.writeHead(req.proxyOptions.status || proxyRes.statusCode, proxyRes.headers)
675+
res.writeHead(options.status || proxyRes.statusCode, proxyRes.headers)
639676

640677
proxyRes.on('data', function onData(data) {
641678
res.write(data)
@@ -656,8 +693,7 @@ const initializeProxy = async function ({
656693
// @ts-expect-error TS(7005) FIXME: Variable 'responseData' implicitly has an 'any[]' ... Remove this comment to see the full error message
657694
let responseBody = Buffer.concat(responseData)
658695

659-
// @ts-expect-error TS(2339) FIXME: Property 'proxyOptions' does not exist on type 'In... Remove this comment to see the full error message
660-
let responseStatus = req.proxyOptions.status || proxyRes.statusCode
696+
let responseStatus = options.status || proxyRes.statusCode
661697

662698
// `req[shouldGenerateETag]` may contain a function that determines
663699
// whether the response should have an ETag header.
@@ -805,11 +841,16 @@ const onRequest = async (
805841
target: `http://${
806842
settings.frameworkHost && isIPv6(settings.frameworkHost) ? `[${settings.frameworkHost}]` : settings.frameworkHost
807843
}:${settings.frameworkPort}`,
844+
detectTarget: settings.detectFrameworkHost,
845+
targetHostname: settings.frameworkHost,
808846
publicFolder: settings.dist,
809847
functionsServer,
810848
functionsPort: settings.functionsPort,
811849
jwtRolePath: settings.jwtRolePath,
812850
framework: settings.framework,
851+
changeSettings(newSettings: Partial<ServerSettings>) {
852+
Object.assign(settings, newSettings)
853+
},
813854
}
814855

815856
if (match) {

src/utils/run-build.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ export const runNetlifyBuild = async ({
114114
})
115115

116116
settings.frameworkHost = ipVersion === 6 ? '::1' : '127.0.0.1'
117+
settings.detectFrameworkHost = options.skipWaitPort
117118
}
118119

119120
if (timeline === 'build') {

src/utils/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export type BaseServerSettings = {
3232
frameworkPort?: number
3333
/** The host where a proxy can listen to it */
3434
frameworkHost?: '127.0.0.1' | '::1'
35+
/** Try both v4 and v6 IPs */
36+
detectFrameworkHost?: boolean
3537
functions?: string
3638
/** The framework name ('Create React App') */
3739
framework?: string

tests/integration/commands/dev/dev.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import fs from 'node:fs/promises'
33
import { type AddressInfo } from 'node:net'
44
import path from 'node:path'
5+
import process from 'process'
56

67
import jwt, { type JwtPayload } from 'jsonwebtoken'
78
import fetch from 'node-fetch'
@@ -397,6 +398,31 @@ describe.concurrent('command/dev', () => {
397398
})
398399
})
399400

401+
test('should detect ipVer when proxying without waiting for port', async (t) => {
402+
// ipv6 is default from node 18
403+
const nodeVer = Number.parseInt(process.versions.node.split('.')[0])
404+
t.expect(nodeVer).toBeGreaterThanOrEqual(18)
405+
406+
await withSiteBuilder(t, async (builder) => {
407+
const externalServer = startExternalServer({
408+
host: '127.0.0.1',
409+
port: 4567,
410+
})
411+
await builder.build()
412+
413+
await withDevServer(
414+
{ cwd: builder.directory, command: 'node', framework: '#custom', targetPort: 4567, skipWaitPort: true },
415+
async (server) => {
416+
const response = await fetch(`${server.url}/test`)
417+
t.expect(response.status).toBe(200)
418+
t.expect(String(server.output)).toContain('Switched host to 127.0.0.1')
419+
},
420+
)
421+
422+
externalServer.close()
423+
})
424+
})
425+
400426
test('should rewrite POST request if content-type is missing and not crash dev server', async (t) => {
401427
await withSiteBuilder(t, async (builder) => {
402428
builder.withNetlifyToml({

tests/integration/utils/dev-server.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,32 +41,37 @@ interface DevServerOptions {
4141
args?: string[]
4242
context?: string
4343
cwd: string
44+
framework?: string
45+
command?: string
4446
debug?: boolean
4547
env?: Record<string, string>
4648
expectFailure?: boolean
4749
offline?: boolean
4850
prompt?: $FIXME[]
4951
serve?: boolean
5052
skipWaitPort?: boolean
53+
targetPort?: number
5154
}
5255

5356
// 240 seconds
5457
const SERVER_START_TIMEOUT = 24e4
5558

5659
const startServer = async ({
5760
args = [],
61+
command,
5862
context = 'dev',
5963
cwd,
6064
debug = false,
6165
env = {},
6266
expectFailure = false,
67+
framework,
6368
offline = true,
6469
prompt,
6570
serve = false,
6671
skipWaitPort = false,
72+
targetPort,
6773
}: DevServerOptions): Promise<DevServer | { timeout: boolean; output: string }> => {
6874
const port = await getPort()
69-
const staticPort = await getPort()
7075
const host = 'localhost'
7176
const url = `http://${host}:${port}`
7277

@@ -77,12 +82,25 @@ const startServer = async ({
7782
offline ? '--offline' : '',
7883
'-p',
7984
port,
80-
'--staticServerPort',
81-
staticPort,
8285
debug ? '--debug' : '',
8386
skipWaitPort ? '--skip-wait-port' : '',
8487
]
8588

89+
if (targetPort) {
90+
baseArgs.push('--target-port', targetPort)
91+
} else {
92+
const staticPort = await getPort()
93+
baseArgs.push('--staticServerPort', staticPort)
94+
}
95+
96+
if (framework) {
97+
baseArgs.push('--framework', framework)
98+
}
99+
100+
if (command) {
101+
baseArgs.push('--command', command)
102+
}
103+
86104
// We use `null` to override the default context and actually omit the flag
87105
// from the command, which is useful in some test scenarios.
88106
if (context !== null) {

tests/integration/utils/external-server.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ import { env } from 'process'
22

33
import express from 'express'
44

5-
export const startExternalServer = ({ port } = {}) => {
5+
export const startExternalServer = ({ host, port } = {}) => {
66
const app = express()
77
app.use(express.urlencoded({ extended: true }))
88
app.all('*', function onRequest(req, res) {
99
res.json({ url: req.url, body: req.body, method: req.method, headers: req.headers, env })
1010
})
1111

12-
return app.listen({ port })
12+
return app.listen({ port, host })
1313
}

0 commit comments

Comments
 (0)