Skip to content

Commit fcdb4e3

Browse files
feat(port-squat): version-gated backward-compat for token check @W-22838968
Webapps using @salesforce/ui-bundle >= 2.0.0 go through the strict X-Live-Preview-Token check from PR #53. Older installs fall back to the pre-fix reachability behavior so existing customers aren't broken. - Add MIN_TOKEN_SUPPORTED_VERSION cutoff (2.0.0) - Read installed @salesforce/ui-bundle version from webapp node_modules - Pre-flight + post-spawn branch on STRICT vs LEGACY mode
1 parent 4457c46 commit fcdb4e3

1 file changed

Lines changed: 92 additions & 9 deletions

File tree

src/commands/ui-bundle/dev.ts

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
*/
1616

1717
import { randomUUID } from 'node:crypto';
18+
import { readFile } from 'node:fs/promises';
19+
import { join } from 'node:path';
1820
import open from 'open';
1921
import select from '@inquirer/select';
2022
import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
@@ -29,6 +31,29 @@ import { discoverUiBundle, DEFAULT_DEV_COMMAND, type DiscoveredUiBundle } from '
2931
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
3032
const 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+
3257
export 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

Comments
 (0)