Skip to content

Commit 0027fb2

Browse files
committed
fix(dev): pin cloudflared version and shut tunnels down gracefully
1 parent a2e7338 commit 0027fb2

3 files changed

Lines changed: 80 additions & 17 deletions

File tree

packages/nuxt-cli/src/dev/binaries.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,17 @@ interface ConsentOptions {
2626
/**
2727
* Resolve a third-party tool binary: from `PATH`, then the download cache,
2828
* then (with the user's consent) by downloading it.
29+
*
30+
* `cacheName` overrides the cached filename, so version-pinned tools can key
31+
* their cache entry by version without affecting the `PATH` lookup.
2932
*/
30-
export async function resolveTool(name: string, options: { url?: string, archive?: boolean, consent: ConsentOptions }): Promise<string | undefined> {
33+
export async function resolveTool(name: string, options: { url?: string, archive?: boolean, cacheName?: string, consent: ConsentOptions }): Promise<string | undefined> {
3134
const existing = findInPath(name)
3235
if (existing) {
3336
return existing
3437
}
35-
const destination = join(getCacheDir('bin'), process.platform === 'win32' ? `${name}.exe` : name)
38+
const cacheName = options.cacheName || name
39+
const destination = join(getCacheDir('bin'), process.platform === 'win32' ? `${cacheName}.exe` : cacheName)
3640
if (existsSync(destination)) {
3741
return destination
3842
}
@@ -129,6 +133,11 @@ async function downloadBinary(url: string, destination: string, options: { archi
129133
writeFileSync(archivePath, data)
130134
execFileSync('tar', ['-xzf', archivePath, '-C', stagingDir], { stdio: 'ignore' })
131135
rmSync(archivePath)
136+
// Archives are named after the tool, not the cache entry.
137+
const extracted = join(stagingDir, options.name || basename(destination))
138+
if (extracted !== staged && existsSync(extracted)) {
139+
renameSync(extracted, staged)
140+
}
132141
}
133142
else {
134143
writeFileSync(staged, data)

packages/nuxt-cli/src/dev/listen.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,8 +197,8 @@ export async function listen(handler: RequestListener, options: ListenOptions =
197197
server,
198198
https: certificate,
199199
getURLs,
200-
close: () => {
201-
tunnel?.close()
200+
close: async () => {
201+
await tunnel?.close()
202202
return new Promise<void>((resolve, reject) => {
203203
server.close(error => (error ? reject(error) : resolve()))
204204
server.closeAllConnections?.()

packages/nuxt-cli/src/dev/tunnel.ts

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
11
import type { Buffer } from 'node:buffer'
22

33
import { spawn } from 'node:child_process'
4+
import { once } from 'node:events'
45
import process from 'node:process'
56

67
import { debug, logger } from '../utils/logger'
78
import { resolveTool } from './binaries'
89

910
const TUNNEL_URL_RE = /https:\/\/(?!api\.)[\w-]+\.trycloudflare\.com/
1011

12+
/**
13+
* Pinned so a bad Cloudflare release cannot break `--tunnel` for everyone.
14+
* Set `CLOUDFLARED_VERSION` (or `latest`) to override.
15+
*/
16+
const CLOUDFLARED_VERSION = process.env.CLOUDFLARED_VERSION || '2026.7.3'
17+
18+
/** How long to wait for `cloudflared` to exit on `SIGINT` before `SIGKILL`. */
19+
const SHUTDOWN_TIMEOUT_MS = 5000
20+
1121
export interface Tunnel {
1222
url: string
13-
close: () => void
23+
close: () => Promise<void>
1424
}
1525

1626
export async function startTunnel(localURL: string, insecure?: boolean): Promise<Tunnel | undefined> {
@@ -27,10 +37,20 @@ export async function startTunnel(localURL: string, insecure?: boolean): Promise
2737
const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'] })
2838

2939
let output = ''
40+
const recentOutput: string[] = []
3041
const url = await new Promise<string | undefined>((resolve) => {
3142
const timeout = setTimeout(resolve, 20_000, undefined)
3243
const onData = (chunk: Buffer) => {
33-
output += chunk.toString()
44+
const text = chunk.toString()
45+
output += text
46+
for (const line of text.split('\n')) {
47+
if (line.trim()) {
48+
recentOutput.push(line.trim())
49+
}
50+
}
51+
if (recentOutput.length > 10) {
52+
recentOutput.splice(0, recentOutput.length - 10)
53+
}
3454
const match = output.match(TUNNEL_URL_RE)
3555
if (match) {
3656
clearTimeout(timeout)
@@ -55,7 +75,10 @@ export async function startTunnel(localURL: string, insecure?: boolean): Promise
5575
if (!url) {
5676
child.kill()
5777
debug('cloudflared output:', output)
58-
logger.warn('Could not establish a Cloudflare quick tunnel. Run with `DEBUG=nuxi` for details.')
78+
logger.warn([
79+
'Could not establish a Cloudflare quick tunnel.',
80+
...recentOutput.map(line => ` ${line}`),
81+
].join('\n'))
5982
return undefined
6083
}
6184

@@ -72,7 +95,7 @@ export async function startTunnel(localURL: string, insecure?: boolean): Promise
7295

7396
const kill = () => {
7497
try {
75-
child.kill()
98+
child.kill('SIGKILL')
7699
}
77100
catch {
78101
// process may have already exited
@@ -82,24 +105,55 @@ export async function startTunnel(localURL: string, insecure?: boolean): Promise
82105

83106
return {
84107
url,
85-
close: () => {
108+
close: async () => {
86109
process.removeListener('exit', kill)
87-
kill()
110+
if (child.exitCode !== null || child.signalCode !== null) {
111+
return
112+
}
113+
// `SIGINT` lets cloudflared drain its connections and deregister the
114+
// quick tunnel; escalate if it does not exit promptly.
115+
const exited = once(child, 'exit')
116+
child.kill('SIGINT')
117+
const escalation = setTimeout(kill, SHUTDOWN_TIMEOUT_MS)
118+
try {
119+
await exited
120+
}
121+
catch (error) {
122+
debug('Failed to stop `cloudflared`:', error)
123+
}
124+
finally {
125+
clearTimeout(escalation)
126+
}
88127
},
89128
}
90129
}
91130

131+
const CLOUDFLARED_ASSETS: Record<string, Partial<Record<typeof process.arch, string>>> = {
132+
darwin: {
133+
arm64: 'cloudflared-darwin-arm64.tgz',
134+
x64: 'cloudflared-darwin-amd64.tgz',
135+
},
136+
linux: {
137+
arm: 'cloudflared-linux-arm',
138+
arm64: 'cloudflared-linux-arm64',
139+
ia32: 'cloudflared-linux-386',
140+
x64: 'cloudflared-linux-amd64',
141+
},
142+
win32: {
143+
ia32: 'cloudflared-windows-386.exe',
144+
x64: 'cloudflared-windows-amd64.exe',
145+
},
146+
}
147+
92148
async function resolveCloudflared(): Promise<string | undefined> {
93-
const arch = process.arch === 'arm64' ? 'arm64' : 'amd64'
94-
const base = 'https://github.com/cloudflare/cloudflared/releases/latest/download'
95-
const asset = ({
96-
darwin: `cloudflared-darwin-${arch}.tgz`,
97-
linux: `cloudflared-linux-${arch}`,
98-
win32: `cloudflared-windows-amd64.exe`,
99-
} as Record<string, string>)[process.platform]
149+
const base = CLOUDFLARED_VERSION === 'latest'
150+
? 'https://github.com/cloudflare/cloudflared/releases/latest/download'
151+
: `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}`
152+
const asset = CLOUDFLARED_ASSETS[process.platform]?.[process.arch]
100153
return resolveTool('cloudflared', {
101154
url: asset && `${base}/${asset}`,
102155
archive: asset?.endsWith('.tgz'),
156+
cacheName: `cloudflared-${CLOUDFLARED_VERSION}`,
103157
consent: {
104158
key: 'cloudflared',
105159
notice: [

0 commit comments

Comments
 (0)