diff --git a/src/lwc-dev-server/index.ts b/src/lwc-dev-server/index.ts index f6c75b81..adfb3993 100644 --- a/src/lwc-dev-server/index.ts +++ b/src/lwc-dev-server/index.ts @@ -7,14 +7,15 @@ import process from 'node:process'; import { LWCServer, ServerConfig, startLwcDevServer, Workspace } from '@lwc/lwc-dev-server'; -import { Lifecycle, Logger, SfProject } from '@salesforce/core'; -import { SSLCertificateData } from '@salesforce/lwc-dev-mobile-core'; +import { Lifecycle, Logger, SfProject, AuthInfo, Connection } from '@salesforce/core'; +import { SSLCertificateData, Platform } from '@salesforce/lwc-dev-mobile-core'; import { glob } from 'glob'; import { ConfigUtils, LOCAL_DEV_SERVER_DEFAULT_HTTP_PORT, LOCAL_DEV_SERVER_DEFAULT_WORKSPACE, } from '../shared/configUtils.js'; +import { PreviewUtils } from '../shared/previewUtils.js'; async function createLWCServerConfig( rootDir: string, @@ -27,7 +28,7 @@ async function createLWCServerConfig( const project = await SfProject.resolve(); const packageDirs = project.getPackageDirectories(); const projectJson = await project.resolveProjectConfig(); - const { namespace } = projectJson; + const { namespace } = projectJson as { namespace?: string }; // e.g. lwc folders in force-app/main/default/lwc, package-dir/lwc const namespacePaths = ( @@ -73,6 +74,9 @@ export async function startLWCServer( certData?: SSLCertificateData, workspace?: Workspace ): Promise { + // Validate JWT authentication before starting the server + await ensureJwtAuth(clientType); + const config = await createLWCServerConfig(rootDir, token, clientType, serverPorts, certData, workspace); logger.trace(`Starting LWC Dev Server with config: ${JSON.stringify(config)}`); @@ -94,3 +98,208 @@ export async function startLWCServer( return lwcDevServer; } + +/** + * Helper function to ensure JWT authentication is valid + */ +async function ensureJwtAuth(username: string): Promise { + try { + // Create AuthInfo - this will throw if authentication is invalid + const authInfo = await AuthInfo.create({ username }); + + // Verify the AuthInfo has valid credentials + const authUsername = authInfo.getUsername(); + if (!authUsername) { + throw new Error('AuthInfo created but username is not available'); + } + + return authInfo; + } catch (e) { + const errorMessage = (e as Error).message; + // Provide more helpful error messages based on common authentication issues + if (errorMessage.includes('No authorization information found')) { + throw new Error( + `JWT authentication not found for user ${username}. Please run 'sf org login jwt' or 'sf org login web' first.` + ); + } else if ( + errorMessage.includes('expired') || + errorMessage.includes('Invalid JWT token') || + errorMessage.includes('invalid signature') + ) { + throw new Error( + `JWT authentication expired or invalid for user ${username}. Please re-authenticate using 'sf org login jwt' or 'sf org login web'.` + ); + } else { + throw new Error(`JWT authentication not found or invalid for user ${username}: ${errorMessage}`); + } + } +} + +/** + * Configuration for starting the local dev server programmatically + */ +export type LocalDevServerConfig = { + /** Target org connection */ + targetOrg: unknown; + /** Component name to preview */ + componentName?: string; + /** Platform for preview (defaults to desktop) */ + platform?: Platform; + /** Custom port configuration */ + ports?: { + httpPort?: number; + httpsPort?: number; + }; + /** Logger instance */ + logger?: Logger; +}; + +/** + * Result from starting the local dev server + */ +export type LocalDevServerResult = { + /** Local dev server URL */ + url: string; + /** Server ID for authentication */ + serverId: string; + /** Authentication token */ + token: string; + /** Server ports */ + ports: { + httpPort: number; + httpsPort: number; + }; + /** Server process for cleanup */ + process?: LWCServer; +}; + +/** + * Programmatic API for starting the local dev server + * This can be used to start the server without CLI + */ +export class LocalDevServerManager { + private static instance: LocalDevServerManager; + private activeServers: Map = new Map(); + + private constructor() {} + + public static getInstance(): LocalDevServerManager { + if (!LocalDevServerManager.instance) { + LocalDevServerManager.instance = new LocalDevServerManager(); + } + return LocalDevServerManager.instance; + } + + /** + * Start the local dev server programmatically + * + * @param config Configuration for the server + * @returns Promise with server details including URL for iframing + */ + public async startServer(config: LocalDevServerConfig): Promise { + const logger = config.logger ?? (await Logger.child('LocalDevServerManager')); + const platform = config.platform ?? Platform.desktop; + + if (typeof config.targetOrg !== 'string') { + const error = new Error('targetOrg must be a valid username string.'); + logger.error('Invalid targetOrg parameter', { targetOrg: config.targetOrg }); + throw error; + } + + logger.info('Starting Local Dev Server', { platform: platform.toString(), targetOrg: config.targetOrg }); + + let sfdxProjectRootPath = ''; + try { + sfdxProjectRootPath = await SfProject.resolveProjectPath(); + logger.debug('SFDX project path resolved', { path: sfdxProjectRootPath }); + } catch (error) { + const errorMessage = `No SFDX project found: ${(error as Error)?.message || ''}`; + logger.error('Failed to resolve SFDX project path', { error: errorMessage }); + throw new Error(errorMessage); + } + + try { + logger.debug('Validating JWT authentication', { targetOrg: config.targetOrg }); + const authInfo = await ensureJwtAuth(config.targetOrg); + const connection = await Connection.create({ authInfo }); + + const ldpServerToken = connection.getConnectionOptions().accessToken; + if (!ldpServerToken) { + const error = new Error( + 'Unable to retrieve access token from targetOrg. Ensure the org is authenticated and has a valid session.' + ); + logger.error('Access token retrieval failed', { targetOrg: config.targetOrg }); + throw error; + } + + const ldpServerId = authInfo.getUsername(); // Using username as server ID + logger.debug('Authentication successful', { serverId: ldpServerId }); + + const serverPorts = config.ports + ? { httpPort: config.ports.httpPort ?? 3333, httpsPort: config.ports.httpsPort ?? 3334 } + : await PreviewUtils.getNextAvailablePorts(); + + logger.debug('Server ports configured', { ports: serverPorts }); + + const ldpServerUrl = PreviewUtils.generateWebSocketUrlForLocalDevServer(platform, serverPorts, logger); + + logger.info('Starting LWC Dev Server process', { ports: serverPorts }); + const serverProcess = await startLWCServer( + logger, + sfdxProjectRootPath, + ldpServerToken, + platform.toString(), + serverPorts + ); + + const result: LocalDevServerResult = { + url: ldpServerUrl, + serverId: ldpServerId, + token: ldpServerToken, + ports: serverPorts, + process: serverProcess, + }; + + // Store active server for cleanup + this.activeServers.set(ldpServerId, result); + + logger.info(`LWC Dev Server started successfully at ${ldpServerUrl}`, { + serverId: ldpServerId, + ports: serverPorts, + url: ldpServerUrl, + }); + + return result; + } catch (error) { + logger.error('Failed to start Local Dev Server', { + error: (error as Error).message, + targetOrg: config.targetOrg, + }); + throw error; + } + } + + /** + * Stop a specific server + * + * @param serverId Server ID to stop + */ + public stopServer(serverId: string): void { + const server = this.activeServers.get(serverId); + if (server?.process) { + server.process.stopServer(); + this.activeServers.delete(serverId); + } + } + + /** + * Stop all active servers + */ + public stopAllServers(): void { + const serverIds = Array.from(this.activeServers.keys()); + serverIds.forEach((serverId) => this.stopServer(serverId)); + } +} + +// Export the new programmatic API +export const localDevServerManager = LocalDevServerManager.getInstance(); diff --git a/test/lwc-dev-server/index.test.ts b/test/lwc-dev-server/index.test.ts index 1dce414b..4bda5533 100644 --- a/test/lwc-dev-server/index.test.ts +++ b/test/lwc-dev-server/index.test.ts @@ -8,7 +8,9 @@ import { expect } from 'chai'; import { LWCServer, Workspace } from '@lwc/lwc-dev-server'; import esmock from 'esmock'; +import sinon from 'sinon'; import { TestContext } from '@salesforce/core/testSetup'; +import { AuthInfo, Logger, SfProject } from '@salesforce/core'; import * as devServer from '../../src/lwc-dev-server/index.js'; import { ConfigUtils } from '../../src/shared/configUtils.js'; @@ -18,6 +20,10 @@ describe('lwc-dev-server', () => { stopServer: () => {}, } as LWCServer; let lwcDevServer: typeof devServer; + let mockLogger: Logger; + let mockProject: Partial; + let getLocalDevServerPortsStub: sinon.SinonStub; + let getLocalDevServerWorkspaceStub: sinon.SinonStub; before(async () => { lwcDevServer = await esmock('../../src/lwc-dev-server/index.js', { @@ -28,8 +34,22 @@ describe('lwc-dev-server', () => { }); beforeEach(async () => { - $$.SANDBOX.stub(ConfigUtils, 'getLocalDevServerPorts').resolves({ httpPort: 1234, httpsPort: 5678 }); - $$.SANDBOX.stub(ConfigUtils, 'getLocalDevServerWorkspace').resolves(Workspace.SfCli); + getLocalDevServerPortsStub = $$.SANDBOX.stub(ConfigUtils, 'getLocalDevServerPorts').resolves({ + httpPort: 1234, + httpsPort: 5678, + }); + getLocalDevServerWorkspaceStub = $$.SANDBOX.stub(ConfigUtils, 'getLocalDevServerWorkspace').resolves( + Workspace.SfCli + ); + + mockLogger = await Logger.child('test'); + mockProject = { + getDefaultPackage: $$.SANDBOX.stub().returns({ fullPath: '/fake/path' }), + getPackageDirectories: $$.SANDBOX.stub().returns([{ fullPath: '/fake/path' }]), + resolveProjectConfig: $$.SANDBOX.stub().resolves({ namespace: '' }), + }; + + $$.SANDBOX.stub(SfProject, 'resolve').resolves(mockProject as unknown as SfProject); }); afterEach(() => { @@ -40,9 +60,88 @@ describe('lwc-dev-server', () => { expect(lwcDevServer.startLWCServer).to.be.a('function'); }); - // it('calling startLWCServer returns an LWCServer', async () => { - // const fakeIdentityToken = 'PFT1vw8v65aXd2b9HFvZ3Zu4OcKZwjI60bq7BEjj5k4='; - // const s = await lwcDevServer.startLWCServer(logger, path.resolve(__dirname, './__mocks__'), fakeIdentityToken, ''); - // expect(s).to.equal(server); - // }); + describe('JWT Authentication Error Handling', () => { + it('should throw helpful error when no authorization information is found', async () => { + const authError = new Error('No authorization information found for user test-user@example.com'); + $$.SANDBOX.stub(AuthInfo, 'create').rejects(authError); + + try { + await lwcDevServer.startLWCServer(mockLogger, '/fake/path', 'fake-token', 'test-user@example.com'); + expect.fail('Expected function to throw an error'); + } catch (error) { + expect(error).to.be.an('error'); + expect((error as Error).message).to.include('JWT authentication not found for user test-user@example.com'); + expect((error as Error).message).to.include("Please run 'sf org login jwt' or 'sf org login web' first"); + } + }); + + it('should throw helpful error when JWT token is expired', async () => { + const authError = new Error('JWT token expired for user test-user@example.com'); + $$.SANDBOX.stub(AuthInfo, 'create').rejects(authError); + + try { + await lwcDevServer.startLWCServer(mockLogger, '/fake/path', 'fake-token', 'test-user@example.com'); + expect.fail('Expected function to throw an error'); + } catch (error) { + expect(error).to.be.an('error'); + expect((error as Error).message).to.include( + 'JWT authentication expired or invalid for user test-user@example.com' + ); + expect((error as Error).message).to.include( + "Please re-authenticate using 'sf org login jwt' or 'sf org login web'" + ); + } + }); + + it('should throw helpful error when JWT token is invalid', async () => { + const authError = new Error('Invalid JWT token for user test-user@example.com'); + $$.SANDBOX.stub(AuthInfo, 'create').rejects(authError); + + try { + await lwcDevServer.startLWCServer(mockLogger, '/fake/path', 'fake-token', 'test-user@example.com'); + expect.fail('Expected function to throw an error'); + } catch (error) { + expect(error).to.be.an('error'); + expect((error as Error).message).to.include( + 'JWT authentication expired or invalid for user test-user@example.com' + ); + expect((error as Error).message).to.include( + "Please re-authenticate using 'sf org login jwt' or 'sf org login web'" + ); + } + }); + + it('should throw helpful error for generic authentication failures', async () => { + const authError = new Error('Some other authentication error'); + $$.SANDBOX.stub(AuthInfo, 'create').rejects(authError); + + try { + await lwcDevServer.startLWCServer(mockLogger, '/fake/path', 'fake-token', 'test-user@example.com'); + expect.fail('Expected function to throw an error'); + } catch (error) { + expect(error).to.be.an('error'); + expect((error as Error).message).to.include( + 'JWT authentication not found or invalid for user test-user@example.com' + ); + expect((error as Error).message).to.include('Some other authentication error'); + } + }); + }); + + it('calling startLWCServer returns an LWCServer', async () => { + const mockAuthInfo = { + getUsername: () => 'test-user@example.com', + }; + const authInfoStub = $$.SANDBOX.stub(AuthInfo, 'create').resolves(mockAuthInfo as unknown as AuthInfo); + + const fakeIdentityToken = 'PFT1vw8v65aXd2b9HFvZ3Zu4OcKZwjI60bq7BEjj5k4='; + + const s = await lwcDevServer.startLWCServer(mockLogger, '/fake/path', fakeIdentityToken, 'test-user@example.com'); + + expect(s).to.equal(server); + expect(getLocalDevServerPortsStub.calledOnce).to.be.true; + expect(getLocalDevServerWorkspaceStub.calledOnce).to.be.true; + + expect(authInfoStub.calledOnceWith({ username: 'test-user@example.com' })).to.be.true; + }); });