1515 */
1616
1717import { randomUUID } from 'node:crypto' ;
18+ import { readFile } from 'node:fs/promises' ;
19+ import { join } from 'node:path' ;
1820import open from 'open' ;
1921import select from '@inquirer/select' ;
2022import { SfCommand , Flags } from '@salesforce/sf-plugins-core' ;
@@ -29,6 +31,29 @@ import { discoverUiBundle, DEFAULT_DEV_COMMAND, type DiscoveredUiBundle } from '
2931Messages . importMessagesDirectoryFromMetaUrl ( import . meta. url ) ;
3032const messages = Messages . loadMessages ( '@salesforce/plugin-ui-bundle-dev' , 'ui-bundle.dev' ) ;
3133
34+ // Webapps with @salesforce /ui-bundle >= this version echo X-Live-Preview-Token
35+ // and go through the strict port-squat check. Older installs fall back to the
36+ // pre-fix reachability check so existing customers aren't broken.
37+ const MIN_TOKEN_SUPPORTED_VERSION = '2.0.0' ;
38+
39+ function compareVersions ( a : string , b : string ) : number {
40+ const norm = ( v : string ) : number [ ] =>
41+ v
42+ . split ( '-' ) [ 0 ]
43+ . split ( '+' ) [ 0 ]
44+ . split ( '.' )
45+ . map ( ( s ) => Number ( s ) || 0 ) ;
46+ const pa = norm ( a ) ;
47+ const pb = norm ( b ) ;
48+ for ( let i = 0 ; i < 3 ; i ++ ) {
49+ const av = pa [ i ] ?? 0 ;
50+ const bv = pb [ i ] ?? 0 ;
51+ if ( av > bv ) return 1 ;
52+ if ( av < bv ) return - 1 ;
53+ }
54+ return 0 ;
55+ }
56+
3257export default class UiBundleDev extends SfCommand < UiBundleDevResult > {
3358 public static readonly summary = messages . getMessage ( 'summary' ) ;
3459 public static readonly description = messages . getMessage ( 'description' ) ;
@@ -103,6 +128,46 @@ export default class UiBundleDev extends SfCommand<UiBundleDevResult> {
103128 } ) ;
104129 }
105130
131+ private static async getWebappUiBundleVersion ( uiBundleDir : string ) : Promise < string | null > {
132+ try {
133+ const pkgPath = join ( uiBundleDir , 'node_modules' , '@salesforce' , 'ui-bundle' , 'package.json' ) ;
134+ const raw = await readFile ( pkgPath , 'utf-8' ) ;
135+ const parsed = JSON . parse ( raw ) as { version ?: unknown } ;
136+ return typeof parsed . version === 'string' ? parsed . version : null ;
137+ } catch {
138+ return null ;
139+ }
140+ }
141+
142+ private static isNewWebapp ( version : string | null ) : boolean {
143+ if ( ! version ) return false ;
144+ return compareVersions ( version , MIN_TOKEN_SUPPORTED_VERSION ) >= 0 ;
145+ }
146+
147+ private static async isUrlReachable ( url : string ) : Promise < boolean > {
148+ try {
149+ const response = await fetch ( url , {
150+ method : 'HEAD' ,
151+ signal : AbortSignal . timeout ( 3000 ) ,
152+ } ) ;
153+ return response . ok ;
154+ } catch {
155+ return false ;
156+ }
157+ }
158+
159+ private static async pollUntilReachable (
160+ url : string ,
161+ timeoutMs : number ,
162+ intervalMs = 500 ,
163+ start = Date . now ( )
164+ ) : Promise < boolean > {
165+ if ( await UiBundleDev . isUrlReachable ( url ) ) return true ;
166+ if ( Date . now ( ) - start >= timeoutMs ) return false ;
167+ await new Promise ( ( r ) => setTimeout ( r , intervalMs ) ) ;
168+ return UiBundleDev . pollUntilReachable ( url , timeoutMs , intervalMs , start ) ;
169+ }
170+
106171 /**
107172 * Check the port status: is it available, or is a server already running there?
108173 * If a server is running, verify its identity via the health check token.
@@ -305,15 +370,31 @@ export default class UiBundleDev extends SfCommand<UiBundleDevResult> {
305370 ) ;
306371 }
307372
308- // Check port status: available, verified (our server), or unverified (foreign)
309- const portStatus = await UiBundleDev . checkPortStatus ( resolvedUrl ) ;
310- if ( portStatus === 'verified' ) {
373+ // Pick security model based on installed @salesforce /ui-bundle version.
374+ const installedUiBundleVersion = await UiBundleDev . getWebappUiBundleVersion ( uiBundleDir ) ;
375+ const useNewSecurityModel = UiBundleDev . isNewWebapp ( installedUiBundleVersion ) ;
376+ this . logger . debug (
377+ `port-squat gate: webapp=${ selectedUiBundle . name } installed=${ installedUiBundleVersion ?? '<unknown>' } ` +
378+ `cutoff=${ MIN_TOKEN_SUPPORTED_VERSION } mode=${ useNewSecurityModel ? 'STRICT' : 'LEGACY' } `
379+ ) ;
380+
381+ let portReachable = false ;
382+ if ( useNewSecurityModel ) {
383+ const portStatus = await UiBundleDev . checkPortStatus ( resolvedUrl ) ;
384+ if ( portStatus === 'verified' ) {
385+ portReachable = true ;
386+ } else if ( portStatus === 'unverified' ) {
387+ process . stderr . write ( JSON . stringify ( { error : 'PortSquattingAbort' , port : resolvedUrl } ) + '\n' ) ;
388+ throw new SfError ( 'Aborted: unverified server on port.' , 'PortSquattingAbort' ) ;
389+ }
390+ } else {
391+ portReachable = await UiBundleDev . isUrlReachable ( resolvedUrl ) ;
392+ }
393+
394+ if ( portReachable ) {
311395 devServerUrl = resolvedUrl ;
312396 this . log ( messages . getMessage ( 'info.url-already-available' , [ resolvedUrl ] ) ) ;
313- this . logger . debug ( `URL ${ resolvedUrl } is verified as our server, skipping dev server startup` ) ;
314- } else if ( portStatus === 'unverified' ) {
315- process . stderr . write ( JSON . stringify ( { error : 'PortSquattingAbort' , port : resolvedUrl } ) + '\n' ) ;
316- throw new SfError ( 'Aborted: unverified server on port.' , 'PortSquattingAbort' ) ;
397+ this . logger . debug ( `URL ${ resolvedUrl } is already available, skipping dev server startup` ) ;
317398 } else if ( flags . url ) {
318399 // User explicitly passed --url; assume server is already running at that URL
319400 // Fail immediately if unreachable (don't start dev server)
@@ -369,8 +450,10 @@ export default class UiBundleDev extends SfCommand<UiBundleDevResult> {
369450
370451 this . devServerManager . start ( ) ;
371452
372- // Poll until our server is verified, or fail on process error / port squatting
373- const pollPromise = UiBundleDev . pollUntilVerified ( resolvedUrl , 60_000 ) ;
453+ // Strict poll aborts on foreign server; legacy poll just waits for reachability.
454+ const pollPromise = useNewSecurityModel
455+ ? UiBundleDev . pollUntilVerified ( resolvedUrl , 60_000 )
456+ : UiBundleDev . pollUntilReachable ( resolvedUrl , 60_000 ) ;
374457 const errorPromise = new Promise < boolean > ( ( _ , reject ) => {
375458 this . devServerManager ! . once ( 'error' , ( error : SfError | DevServerError ) => {
376459 const devError =
0 commit comments