diff --git a/src/commands/ui-bundle/dev.ts b/src/commands/ui-bundle/dev.ts index b8d4ec1..9ac69c2 100644 --- a/src/commands/ui-bundle/dev.ts +++ b/src/commands/ui-bundle/dev.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { randomUUID } from 'node:crypto'; import open from 'open'; import select from '@inquirer/select'; import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; @@ -103,44 +104,61 @@ export default class UiBundleDev extends SfCommand { } /** - * Check if a URL is reachable (returns true/false) - * Used to check if --url is already available before starting dev server + * Check the port status: is it available, or is a server already running there? + * If a server is running, verify its identity via the health check token. + * + * @returns 'available' if no server is running, 'verified' if the running server's + * token matches ours, 'unverified' if a server is running but cannot be confirmed. */ - private static async isUrlReachable(url: string): Promise { + private static async checkPortStatus(url: string): Promise<'available' | 'verified' | 'unverified'> { + const expectedToken = process.env.SF_LIVE_PREVIEW_TOKEN; + try { - const response = await fetch(url, { - method: 'HEAD', - signal: AbortSignal.timeout(3000), // 3 second timeout + const healthUrl = new URL(url); + healthUrl.searchParams.set('sfProxyHealthCheck', 'true'); + const response = await fetch(healthUrl.toString(), { + method: 'GET', + signal: AbortSignal.timeout(3000), }); - return response.ok; + if (!response.ok || !expectedToken) return 'unverified'; + const token = response.headers.get('X-Live-Preview-Token'); + return token === expectedToken ? 'verified' : 'unverified'; } catch { - return false; + return 'available'; } } /** - * Poll a URL until it is reachable or timeout. + * Poll a URL until our verified server is detected, or abort if a foreign server appears. + * Uses checkPortStatus on each iteration so every poll verifies identity via the + * X-Live-Preview-Token header — no race window between "is it up?" and "is it ours?". * - * @param url - URL to poll (HEAD request) + * @param url - URL to poll * @param timeoutMs - Max time to wait * @param intervalMs - Poll interval * @param start - Start timestamp (for recursion) - * @returns true if reachable within timeout + * @returns true if verified within timeout, false on timeout + * @throws SfError with name 'PortSquattingAbort' if a foreign server is detected */ - private static async pollUntilReachable( + private static async pollUntilVerified( url: string, timeoutMs: number, intervalMs = 500, start = Date.now() ): Promise { - if (await UiBundleDev.isUrlReachable(url)) { - return true; - } + const status = await UiBundleDev.checkPortStatus(url); + if (status === 'verified') return true; + // 'available' — server not up yet, keep polling + // 'unverified' — server may still be initializing (proxy plugin not ready), keep polling if (Date.now() - start >= timeoutMs) { + if (status === 'unverified') { + process.stderr.write(JSON.stringify({ error: 'PortSquattingAbort', port: url }) + '\n'); + throw new SfError('Aborted: unverified server on port.', 'PortSquattingAbort'); + } return false; } await new Promise((r) => setTimeout(r, intervalMs)); - return UiBundleDev.pollUntilReachable(url, timeoutMs, intervalMs, start); + return UiBundleDev.pollUntilVerified(url, timeoutMs, intervalMs, start); } /** @@ -175,6 +193,11 @@ export default class UiBundleDev extends SfCommand { // Logger respects SF_LOG_LEVEL environment variable this.logger = await Logger.child('UiBundleDev'); + // Ensure a live preview token exists — self-generate if the extension didn't provide one + if (!process.env.SF_LIVE_PREVIEW_TOKEN) { + process.env.SF_LIVE_PREVIEW_TOKEN = randomUUID(); + } + // Declare variables outside try block for catch block access let manifest: UiBundleManifest | null = null; let devServerUrl: string | null = null; @@ -282,12 +305,15 @@ export default class UiBundleDev extends SfCommand { ); } - // Check if URL is already reachable - const isReachable = await UiBundleDev.isUrlReachable(resolvedUrl); - if (isReachable) { + // Check port status: available, verified (our server), or unverified (foreign) + const portStatus = await UiBundleDev.checkPortStatus(resolvedUrl); + if (portStatus === 'verified') { devServerUrl = resolvedUrl; this.log(messages.getMessage('info.url-already-available', [resolvedUrl])); - this.logger.debug(`URL ${resolvedUrl} is reachable, skipping dev server startup`); + this.logger.debug(`URL ${resolvedUrl} is verified as our server, skipping dev server startup`); + } else if (portStatus === 'unverified') { + process.stderr.write(JSON.stringify({ error: 'PortSquattingAbort', port: resolvedUrl }) + '\n'); + throw new SfError('Aborted: unverified server on port.', 'PortSquattingAbort'); } else if (flags.url) { // User explicitly passed --url; assume server is already running at that URL // Fail immediately if unreachable (don't start dev server) @@ -343,8 +369,8 @@ export default class UiBundleDev extends SfCommand { this.devServerManager.start(); - // Poll until URL is reachable, or fail immediately on process error - const pollPromise = UiBundleDev.pollUntilReachable(resolvedUrl, 60_000); + // Poll until our server is verified, or fail on process error / port squatting + const pollPromise = UiBundleDev.pollUntilVerified(resolvedUrl, 60_000); const errorPromise = new Promise((_, reject) => { this.devServerManager!.once('error', (error: SfError | DevServerError) => { const devError = diff --git a/test/commands/ui-bundle/devPort.nut.ts b/test/commands/ui-bundle/devPort.nut.ts index b2736b6..812d466 100644 --- a/test/commands/ui-bundle/devPort.nut.ts +++ b/test/commands/ui-bundle/devPort.nut.ts @@ -57,6 +57,10 @@ describe('ui-bundle dev NUTs — Tier 2 port handling', function () { ); } + // Set a known token so test dev servers can respond with it on health checks, + // simulating the IDE extension → Vite plugin → CLI verification flow. + process.env.SF_LIVE_PREVIEW_TOKEN = 'test-token-port-nuts'; + session = await TestSession.create({ devhubAuthStrategy: 'NONE' }); targetOrg = authOrgViaUrl(); }); diff --git a/test/commands/ui-bundle/devWithUrl.nut.ts b/test/commands/ui-bundle/devWithUrl.nut.ts index 64a8833..68d5c4e 100644 --- a/test/commands/ui-bundle/devWithUrl.nut.ts +++ b/test/commands/ui-bundle/devWithUrl.nut.ts @@ -64,6 +64,10 @@ describe('ui-bundle dev NUTs — Tier 2 URL/proxy integration', function () { ); } + // Set a known token so test dev servers can respond with it on health checks, + // simulating the IDE extension → Vite plugin → CLI verification flow. + process.env.SF_LIVE_PREVIEW_TOKEN = 'test-token-url-nuts'; + session = await TestSession.create({ devhubAuthStrategy: 'NONE' }); targetOrg = authOrgViaUrl(); }); diff --git a/test/commands/ui-bundle/helpers/devServerUtils.ts b/test/commands/ui-bundle/helpers/devServerUtils.ts index a2a2e11..098d61f 100644 --- a/test/commands/ui-bundle/helpers/devServerUtils.ts +++ b/test/commands/ui-bundle/helpers/devServerUtils.ts @@ -164,10 +164,21 @@ export function occupyPort(port: number): Promise { /** * Start a plain HTTP server that serves static HTML content. * Used for proxy-only mode tests where the dev server is already running. + * Responds to ?sfProxyHealthCheck=true with X-Live-Preview-Token (mimics the + * Vite proxy plugin contract for port squatting verification). */ export function startTestHttpServer(port: number): Promise { return new Promise((resolve, reject) => { - const server = createHttpServer((_, res) => { + const server = createHttpServer((req, res) => { + const url = new URL(req.url ?? '/', `http://localhost:${port}`); + if (url.searchParams.get('sfProxyHealthCheck') === 'true') { + res.writeHead(200, { + 'Content-Type': 'text/plain', + 'X-Live-Preview-Token': process.env.SF_LIVE_PREVIEW_TOKEN ?? '', + }); + res.end('OK'); + return; + } res.writeHead(200, { 'Content-Type': 'text/html' }); res.end('

Manual Dev Server

'); }); @@ -189,6 +200,7 @@ export function startViteProxyServer(port: number): Promise { res.writeHead(200, { 'Content-Type': 'text/plain', 'X-Salesforce-UiBundle-Proxy': 'true', + 'X-Live-Preview-Token': process.env.SF_LIVE_PREVIEW_TOKEN ?? '', }); res.end('OK'); return; diff --git a/test/commands/ui-bundle/helpers/uiBundleProjectUtils.ts b/test/commands/ui-bundle/helpers/uiBundleProjectUtils.ts index 0ae41db..caf98a2 100644 --- a/test/commands/ui-bundle/helpers/uiBundleProjectUtils.ts +++ b/test/commands/ui-bundle/helpers/uiBundleProjectUtils.ts @@ -143,7 +143,16 @@ export function writeManifest(projectDir: string, uiBundleName: string, manifest function createDevServerScript(uiBundleDir: string, port: number): string { const script = [ "const http = require('http');", - 'const server = http.createServer((_, res) => {', + 'const server = http.createServer((req, res) => {', + ` const url = new URL(req.url || '/', 'http://localhost:${port}');`, + " if (url.searchParams.get('sfProxyHealthCheck') === 'true') {", + ' res.writeHead(200, {', + " 'Content-Type': 'text/plain',", + " 'X-Live-Preview-Token': process.env.SF_LIVE_PREVIEW_TOKEN || '',", + ' });', + " res.end('OK');", + ' return;', + ' }', " res.writeHead(200, { 'Content-Type': 'text/html' });", " res.end('

Test Dev Server

');", '});',