11import type { Buffer } from 'node:buffer'
22
33import { spawn } from 'node:child_process'
4+ import { once } from 'node:events'
45import process from 'node:process'
56
67import { debug , logger } from '../utils/logger'
78import { resolveTool } from './binaries'
89
910const TUNNEL_URL_RE = / h t t p s : \/ \/ (? ! a p i \. ) [ \w - ] + \. t r y c l o u d f l a r e \. c o m /
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+
1121export interface Tunnel {
1222 url : string
13- close : ( ) => void
23+ close : ( ) => Promise < void >
1424}
1525
1626export 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+
92148async 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