Skip to content

@W-19036340 feat: add programmatic API for LWC dev server with JWT authentication #455

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 212 additions & 3 deletions src/lwc-dev-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = (
Expand Down Expand Up @@ -73,6 +74,9 @@ export async function startLWCServer(
certData?: SSLCertificateData,
workspace?: Workspace
): Promise<LWCServer> {
// 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)}`);
Expand All @@ -94,3 +98,208 @@ export async function startLWCServer(

return lwcDevServer;
}

/**
* Helper function to ensure JWT authentication is valid
*/
async function ensureJwtAuth(username: string): Promise<AuthInfo> {
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if this is the right place for this API. Other consumers would be taking a dependency on the CLI plugin to access this API which seems a little odd.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Shinoni Lets hold off on merging this for now. I'm exploring a couple options for how we can do this with the CLI folks and then we can make a decision on where this code should live.

*/
export class LocalDevServerManager {
private static instance: LocalDevServerManager;
private activeServers: Map<string, LocalDevServerResult> = 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<LocalDevServerResult> {
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();
113 changes: 106 additions & 7 deletions test/lwc-dev-server/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -18,6 +20,10 @@ describe('lwc-dev-server', () => {
stopServer: () => {},
} as LWCServer;
let lwcDevServer: typeof devServer;
let mockLogger: Logger;
let mockProject: Partial<SfProject>;
let getLocalDevServerPortsStub: sinon.SinonStub;
let getLocalDevServerWorkspaceStub: sinon.SinonStub;

before(async () => {
lwcDevServer = await esmock<typeof devServer>('../../src/lwc-dev-server/index.js', {
Expand All @@ -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(() => {
Expand All @@ -40,9 +60,88 @@ describe('lwc-dev-server', () => {
expect(lwcDevServer.startLWCServer).to.be.a('function');
});

// it('calling startLWCServer returns an LWCServer', async () => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I uncommented and fixed this test.

// 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;
});
});
Loading