Skip to content

Commit 377538f

Browse files
committed
feat: add programmatic API for LWC dev server with JWT authentication
1 parent 92a0c28 commit 377538f

File tree

2 files changed

+318
-10
lines changed

2 files changed

+318
-10
lines changed

src/lwc-dev-server/index.ts

Lines changed: 212 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@
77

88
import process from 'node:process';
99
import { LWCServer, ServerConfig, startLwcDevServer, Workspace } from '@lwc/lwc-dev-server';
10-
import { Lifecycle, Logger, SfProject } from '@salesforce/core';
11-
import { SSLCertificateData } from '@salesforce/lwc-dev-mobile-core';
10+
import { Lifecycle, Logger, SfProject, AuthInfo, Connection } from '@salesforce/core';
11+
import { SSLCertificateData, Platform } from '@salesforce/lwc-dev-mobile-core';
1212
import { glob } from 'glob';
1313
import {
1414
ConfigUtils,
1515
LOCAL_DEV_SERVER_DEFAULT_HTTP_PORT,
1616
LOCAL_DEV_SERVER_DEFAULT_WORKSPACE,
1717
} from '../shared/configUtils.js';
18+
import { PreviewUtils } from '../shared/previewUtils.js';
1819

1920
async function createLWCServerConfig(
2021
rootDir: string,
@@ -27,7 +28,7 @@ async function createLWCServerConfig(
2728
const project = await SfProject.resolve();
2829
const packageDirs = project.getPackageDirectories();
2930
const projectJson = await project.resolveProjectConfig();
30-
const { namespace } = projectJson;
31+
const { namespace } = projectJson as { namespace?: string };
3132

3233
// e.g. lwc folders in force-app/main/default/lwc, package-dir/lwc
3334
const namespacePaths = (
@@ -73,6 +74,9 @@ export async function startLWCServer(
7374
certData?: SSLCertificateData,
7475
workspace?: Workspace
7576
): Promise<LWCServer> {
77+
// Validate JWT authentication before starting the server
78+
await ensureJwtAuth(clientType);
79+
7680
const config = await createLWCServerConfig(rootDir, token, clientType, serverPorts, certData, workspace);
7781

7882
logger.trace(`Starting LWC Dev Server with config: ${JSON.stringify(config)}`);
@@ -94,3 +98,208 @@ export async function startLWCServer(
9498

9599
return lwcDevServer;
96100
}
101+
102+
/**
103+
* Helper function to ensure JWT authentication is valid
104+
*/
105+
async function ensureJwtAuth(username: string): Promise<AuthInfo> {
106+
try {
107+
// Create AuthInfo - this will throw if authentication is invalid
108+
const authInfo = await AuthInfo.create({ username });
109+
110+
// Verify the AuthInfo has valid credentials
111+
const authUsername = authInfo.getUsername();
112+
if (!authUsername) {
113+
throw new Error('AuthInfo created but username is not available');
114+
}
115+
116+
return authInfo;
117+
} catch (e) {
118+
const errorMessage = (e as Error).message;
119+
// Provide more helpful error messages based on common authentication issues
120+
if (errorMessage.includes('No authorization information found')) {
121+
throw new Error(
122+
`JWT authentication not found for user ${username}. Please run 'sf org login jwt' or 'sf org login web' first.`
123+
);
124+
} else if (
125+
errorMessage.includes('expired') ||
126+
errorMessage.includes('Invalid JWT token') ||
127+
errorMessage.includes('invalid signature')
128+
) {
129+
throw new Error(
130+
`JWT authentication expired or invalid for user ${username}. Please re-authenticate using 'sf org login jwt' or 'sf org login web'.`
131+
);
132+
} else {
133+
throw new Error(`JWT authentication not found or invalid for user ${username}: ${errorMessage}`);
134+
}
135+
}
136+
}
137+
138+
/**
139+
* Configuration for starting the local dev server programmatically
140+
*/
141+
export type LocalDevServerConfig = {
142+
/** Target org connection */
143+
targetOrg: unknown;
144+
/** Component name to preview */
145+
componentName?: string;
146+
/** Platform for preview (defaults to desktop) */
147+
platform?: Platform;
148+
/** Custom port configuration */
149+
ports?: {
150+
httpPort?: number;
151+
httpsPort?: number;
152+
};
153+
/** Logger instance */
154+
logger?: Logger;
155+
};
156+
157+
/**
158+
* Result from starting the local dev server
159+
*/
160+
export type LocalDevServerResult = {
161+
/** Local dev server URL */
162+
url: string;
163+
/** Server ID for authentication */
164+
serverId: string;
165+
/** Authentication token */
166+
token: string;
167+
/** Server ports */
168+
ports: {
169+
httpPort: number;
170+
httpsPort: number;
171+
};
172+
/** Server process for cleanup */
173+
process?: LWCServer;
174+
};
175+
176+
/**
177+
* Programmatic API for starting the local dev server
178+
* This can be used to start the server without CLI
179+
*/
180+
export class LocalDevServerManager {
181+
private static instance: LocalDevServerManager;
182+
private activeServers: Map<string, LocalDevServerResult> = new Map();
183+
184+
private constructor() {}
185+
186+
public static getInstance(): LocalDevServerManager {
187+
if (!LocalDevServerManager.instance) {
188+
LocalDevServerManager.instance = new LocalDevServerManager();
189+
}
190+
return LocalDevServerManager.instance;
191+
}
192+
193+
/**
194+
* Start the local dev server programmatically
195+
*
196+
* @param config Configuration for the server
197+
* @returns Promise with server details including URL for iframing
198+
*/
199+
public async startServer(config: LocalDevServerConfig): Promise<LocalDevServerResult> {
200+
const logger = config.logger ?? (await Logger.child('LocalDevServerManager'));
201+
const platform = config.platform ?? Platform.desktop;
202+
203+
if (typeof config.targetOrg !== 'string') {
204+
const error = new Error('targetOrg must be a valid username string.');
205+
logger.error('Invalid targetOrg parameter', { targetOrg: config.targetOrg });
206+
throw error;
207+
}
208+
209+
logger.info('Starting Local Dev Server', { platform: platform.toString(), targetOrg: config.targetOrg });
210+
211+
let sfdxProjectRootPath = '';
212+
try {
213+
sfdxProjectRootPath = await SfProject.resolveProjectPath();
214+
logger.debug('SFDX project path resolved', { path: sfdxProjectRootPath });
215+
} catch (error) {
216+
const errorMessage = `No SFDX project found: ${(error as Error)?.message || ''}`;
217+
logger.error('Failed to resolve SFDX project path', { error: errorMessage });
218+
throw new Error(errorMessage);
219+
}
220+
221+
try {
222+
logger.debug('Validating JWT authentication', { targetOrg: config.targetOrg });
223+
const authInfo = await ensureJwtAuth(config.targetOrg);
224+
const connection = await Connection.create({ authInfo });
225+
226+
const ldpServerToken = connection.getConnectionOptions().accessToken;
227+
if (!ldpServerToken) {
228+
const error = new Error(
229+
'Unable to retrieve access token from targetOrg. Ensure the org is authenticated and has a valid session.'
230+
);
231+
logger.error('Access token retrieval failed', { targetOrg: config.targetOrg });
232+
throw error;
233+
}
234+
235+
const ldpServerId = authInfo.getUsername(); // Using username as server ID
236+
logger.debug('Authentication successful', { serverId: ldpServerId });
237+
238+
const serverPorts = config.ports
239+
? { httpPort: config.ports.httpPort ?? 3333, httpsPort: config.ports.httpsPort ?? 3334 }
240+
: await PreviewUtils.getNextAvailablePorts();
241+
242+
logger.debug('Server ports configured', { ports: serverPorts });
243+
244+
const ldpServerUrl = PreviewUtils.generateWebSocketUrlForLocalDevServer(platform, serverPorts, logger);
245+
246+
logger.info('Starting LWC Dev Server process', { ports: serverPorts });
247+
const serverProcess = await startLWCServer(
248+
logger,
249+
sfdxProjectRootPath,
250+
ldpServerToken,
251+
platform.toString(),
252+
serverPorts
253+
);
254+
255+
const result: LocalDevServerResult = {
256+
url: ldpServerUrl,
257+
serverId: ldpServerId,
258+
token: ldpServerToken,
259+
ports: serverPorts,
260+
process: serverProcess,
261+
};
262+
263+
// Store active server for cleanup
264+
this.activeServers.set(ldpServerId, result);
265+
266+
logger.info(`LWC Dev Server started successfully at ${ldpServerUrl}`, {
267+
serverId: ldpServerId,
268+
ports: serverPorts,
269+
url: ldpServerUrl,
270+
});
271+
272+
return result;
273+
} catch (error) {
274+
logger.error('Failed to start Local Dev Server', {
275+
error: (error as Error).message,
276+
targetOrg: config.targetOrg,
277+
});
278+
throw error;
279+
}
280+
}
281+
282+
/**
283+
* Stop a specific server
284+
*
285+
* @param serverId Server ID to stop
286+
*/
287+
public stopServer(serverId: string): void {
288+
const server = this.activeServers.get(serverId);
289+
if (server?.process) {
290+
server.process.stopServer();
291+
this.activeServers.delete(serverId);
292+
}
293+
}
294+
295+
/**
296+
* Stop all active servers
297+
*/
298+
public stopAllServers(): void {
299+
const serverIds = Array.from(this.activeServers.keys());
300+
serverIds.forEach((serverId) => this.stopServer(serverId));
301+
}
302+
}
303+
304+
// Export the new programmatic API
305+
export const localDevServerManager = LocalDevServerManager.getInstance();

test/lwc-dev-server/index.test.ts

Lines changed: 106 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
import { expect } from 'chai';
99
import { LWCServer, Workspace } from '@lwc/lwc-dev-server';
1010
import esmock from 'esmock';
11+
import sinon from 'sinon';
1112
import { TestContext } from '@salesforce/core/testSetup';
13+
import { AuthInfo, Logger, SfProject } from '@salesforce/core';
1214
import * as devServer from '../../src/lwc-dev-server/index.js';
1315
import { ConfigUtils } from '../../src/shared/configUtils.js';
1416

@@ -18,6 +20,10 @@ describe('lwc-dev-server', () => {
1820
stopServer: () => {},
1921
} as LWCServer;
2022
let lwcDevServer: typeof devServer;
23+
let mockLogger: Logger;
24+
let mockProject: Partial<SfProject>;
25+
let getLocalDevServerPortsStub: sinon.SinonStub;
26+
let getLocalDevServerWorkspaceStub: sinon.SinonStub;
2127

2228
before(async () => {
2329
lwcDevServer = await esmock<typeof devServer>('../../src/lwc-dev-server/index.js', {
@@ -28,8 +34,22 @@ describe('lwc-dev-server', () => {
2834
});
2935

3036
beforeEach(async () => {
31-
$$.SANDBOX.stub(ConfigUtils, 'getLocalDevServerPorts').resolves({ httpPort: 1234, httpsPort: 5678 });
32-
$$.SANDBOX.stub(ConfigUtils, 'getLocalDevServerWorkspace').resolves(Workspace.SfCli);
37+
getLocalDevServerPortsStub = $$.SANDBOX.stub(ConfigUtils, 'getLocalDevServerPorts').resolves({
38+
httpPort: 1234,
39+
httpsPort: 5678,
40+
});
41+
getLocalDevServerWorkspaceStub = $$.SANDBOX.stub(ConfigUtils, 'getLocalDevServerWorkspace').resolves(
42+
Workspace.SfCli
43+
);
44+
45+
mockLogger = await Logger.child('test');
46+
mockProject = {
47+
getDefaultPackage: $$.SANDBOX.stub().returns({ fullPath: '/fake/path' }),
48+
getPackageDirectories: $$.SANDBOX.stub().returns([{ fullPath: '/fake/path' }]),
49+
resolveProjectConfig: $$.SANDBOX.stub().resolves({ namespace: '' }),
50+
};
51+
52+
$$.SANDBOX.stub(SfProject, 'resolve').resolves(mockProject as unknown as SfProject);
3353
});
3454

3555
afterEach(() => {
@@ -40,9 +60,88 @@ describe('lwc-dev-server', () => {
4060
expect(lwcDevServer.startLWCServer).to.be.a('function');
4161
});
4262

43-
// it('calling startLWCServer returns an LWCServer', async () => {
44-
// const fakeIdentityToken = 'PFT1vw8v65aXd2b9HFvZ3Zu4OcKZwjI60bq7BEjj5k4=';
45-
// const s = await lwcDevServer.startLWCServer(logger, path.resolve(__dirname, './__mocks__'), fakeIdentityToken, '');
46-
// expect(s).to.equal(server);
47-
// });
63+
describe('JWT Authentication Error Handling', () => {
64+
it('should throw helpful error when no authorization information is found', async () => {
65+
const authError = new Error('No authorization information found for user [email protected]');
66+
$$.SANDBOX.stub(AuthInfo, 'create').rejects(authError);
67+
68+
try {
69+
await lwcDevServer.startLWCServer(mockLogger, '/fake/path', 'fake-token', '[email protected]');
70+
expect.fail('Expected function to throw an error');
71+
} catch (error) {
72+
expect(error).to.be.an('error');
73+
expect((error as Error).message).to.include('JWT authentication not found for user [email protected]');
74+
expect((error as Error).message).to.include("Please run 'sf org login jwt' or 'sf org login web' first");
75+
}
76+
});
77+
78+
it('should throw helpful error when JWT token is expired', async () => {
79+
const authError = new Error('JWT token expired for user [email protected]');
80+
$$.SANDBOX.stub(AuthInfo, 'create').rejects(authError);
81+
82+
try {
83+
await lwcDevServer.startLWCServer(mockLogger, '/fake/path', 'fake-token', '[email protected]');
84+
expect.fail('Expected function to throw an error');
85+
} catch (error) {
86+
expect(error).to.be.an('error');
87+
expect((error as Error).message).to.include(
88+
'JWT authentication expired or invalid for user [email protected]'
89+
);
90+
expect((error as Error).message).to.include(
91+
"Please re-authenticate using 'sf org login jwt' or 'sf org login web'"
92+
);
93+
}
94+
});
95+
96+
it('should throw helpful error when JWT token is invalid', async () => {
97+
const authError = new Error('Invalid JWT token for user [email protected]');
98+
$$.SANDBOX.stub(AuthInfo, 'create').rejects(authError);
99+
100+
try {
101+
await lwcDevServer.startLWCServer(mockLogger, '/fake/path', 'fake-token', '[email protected]');
102+
expect.fail('Expected function to throw an error');
103+
} catch (error) {
104+
expect(error).to.be.an('error');
105+
expect((error as Error).message).to.include(
106+
'JWT authentication expired or invalid for user [email protected]'
107+
);
108+
expect((error as Error).message).to.include(
109+
"Please re-authenticate using 'sf org login jwt' or 'sf org login web'"
110+
);
111+
}
112+
});
113+
114+
it('should throw helpful error for generic authentication failures', async () => {
115+
const authError = new Error('Some other authentication error');
116+
$$.SANDBOX.stub(AuthInfo, 'create').rejects(authError);
117+
118+
try {
119+
await lwcDevServer.startLWCServer(mockLogger, '/fake/path', 'fake-token', '[email protected]');
120+
expect.fail('Expected function to throw an error');
121+
} catch (error) {
122+
expect(error).to.be.an('error');
123+
expect((error as Error).message).to.include(
124+
'JWT authentication not found or invalid for user [email protected]'
125+
);
126+
expect((error as Error).message).to.include('Some other authentication error');
127+
}
128+
});
129+
});
130+
131+
it('calling startLWCServer returns an LWCServer', async () => {
132+
const mockAuthInfo = {
133+
getUsername: () => '[email protected]',
134+
};
135+
const authInfoStub = $$.SANDBOX.stub(AuthInfo, 'create').resolves(mockAuthInfo as unknown as AuthInfo);
136+
137+
const fakeIdentityToken = 'PFT1vw8v65aXd2b9HFvZ3Zu4OcKZwjI60bq7BEjj5k4=';
138+
139+
const s = await lwcDevServer.startLWCServer(mockLogger, '/fake/path', fakeIdentityToken, '[email protected]');
140+
141+
expect(s).to.equal(server);
142+
expect(getLocalDevServerPortsStub.calledOnce).to.be.true;
143+
expect(getLocalDevServerWorkspaceStub.calledOnce).to.be.true;
144+
145+
expect(authInfoStub.calledOnceWith({ username: '[email protected]' })).to.be.true;
146+
});
48147
});

0 commit comments

Comments
 (0)