Skip to content
Merged
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
43 changes: 3 additions & 40 deletions src/commands/lightning/dev/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
Platform,
} from '@salesforce/lwc-dev-mobile-core';
import { Flags, SfCommand } from '@salesforce/sf-plugins-core';
import { OrgUtils } from '../../../shared/orgUtils.js';
import { startLWCServer } from '../../../lwc-dev-server/index.js';
import { PreviewUtils } from '../../../shared/previewUtils.js';
import { PromptUtils } from '../../../shared/promptUtils.js';
Expand Down Expand Up @@ -77,29 +76,11 @@ export default class LightningDevApp extends SfCommand<void> {
throw new Error(sharedMessages.getMessage('error.no-project', [(error as Error)?.message ?? '']));
}

const connection = targetOrg.getConnection(undefined);
const username = connection.getUsername();
if (!username) {
throw new Error(sharedMessages.getMessage('error.username'));
}

const localDevEnabled = await OrgUtils.isLocalDevEnabled(connection);
if (!localDevEnabled) {
throw new Error(sharedMessages.getMessage('error.localdev.not.enabled'));
}

OrgUtils.ensureMatchingAPIVersion(connection);
logger.debug('Initalizing preview connection and configuring local web server identity');
const { connection, ldpServerId, ldpServerToken } = await PreviewUtils.initializePreviewConnection(targetOrg);

const platform = flags['device-type'] ?? (await PromptUtils.promptUserToSelectPlatform());

logger.debug('Configuring local web server identity');
const appServerIdentity = await PreviewUtils.getOrCreateAppServerIdentity(connection);
const ldpServerToken = appServerIdentity.identityToken;
const ldpServerId = appServerIdentity.usernameToServerEntityIdMap[username];
if (!ldpServerId) {
throw new Error(sharedMessages.getMessage('error.identitydata.entityid'));
}

const appId = await PreviewUtils.getLightningExperienceAppId(connection, appName, logger);

logger.debug('Determining the next available port for Local Dev Server');
Expand Down Expand Up @@ -149,25 +130,7 @@ export default class LightningDevApp extends SfCommand<void> {
logger.debug('No Lightning Experience application name provided.... using the default app instead.');
}

// There are various ways to pass in a target org (as an alias, as a username, etc).
// We could have LightningPreviewApp parse its --target-org flag which will be resolved
// to an Org object (see https://github.com/forcedotcom/sfdx-core/blob/main/src/org/org.ts)
// then write a bunch of code to look at this Org object to try to determine whether
// it was initialized using Alias, Username, etc. and get a string representation of the
// org to be forwarded to OrgOpenCommand.
//
// Or we could simply look at the raw arguments passed to the LightningPreviewApp command,
// find the raw value for --target-org flag and forward that raw value to OrgOpenCommand.
// The OrgOpenCommand will then parse the raw value automatically. If the value is
// valid then OrgOpenCommand will consume it and continue. And if the value is invalid then
// OrgOpenCommand simply throws an error which will get bubbled up to LightningPreviewApp.
//
// Here we've chosen the second approach
const idx = this.argv.findIndex((item) => item.toLowerCase() === '-o' || item.toLowerCase() === '--target-org');
let targetOrg: string | undefined;
if (idx >= 0 && idx < this.argv.length - 1) {
targetOrg = this.argv[idx + 1];
}
const targetOrg = PreviewUtils.getTargetOrgFromArguments(this.argv);

if (ldpServerUrl.startsWith('wss')) {
this.log(`\n${messages.getMessage('trust.local.dev.server')}`);
Expand Down
83 changes: 54 additions & 29 deletions src/commands/lightning/dev/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@
*/

import path from 'node:path';
import url from 'node:url';
import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
import { Messages, SfProject } from '@salesforce/core';
import { cmpDev } from '@lwrjs/api';
import { Messages, SfProject, Logger } from '@salesforce/core';
import { Platform } from '@salesforce/lwc-dev-mobile-core';
import { ComponentUtils } from '../../../shared/componentUtils.js';
import { PromptUtils } from '../../../shared/promptUtils.js';
import { PreviewUtils } from '../../../shared/previewUtils.js';
import { startLWCServer } from '../../../lwc-dev-server/index.js';

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('@salesforce/plugin-lightning-dev', 'lightning.dev.component');
const sharedMessages = Messages.loadMessages('@salesforce/plugin-lightning-dev', 'shared.utils');

export default class LightningDevComponent extends SfCommand<void> {
public static readonly summary = messages.getMessage('summary');
Expand All @@ -32,15 +34,37 @@ export default class LightningDevComponent extends SfCommand<void> {
char: 'c',
default: false,
}),
// TODO should this be required or optional?
// We don't technically need this if your components are simple / don't need any data from your org
'target-org': Flags.optionalOrg(),
'target-org': Flags.requiredOrg(),
};

public async run(): Promise<void> {
const { flags } = await this.parse(LightningDevComponent);
const logger = await Logger.child(this.ctor.name);
const project = await SfProject.resolve();

let sfdxProjectRootPath = '';
try {
sfdxProjectRootPath = await SfProject.resolveProjectPath();
} catch (error) {
return Promise.reject(
new Error(sharedMessages.getMessage('error.no-project', [(error as Error)?.message ?? '']))
);
}

let componentName = flags['name'];
const clientSelect = flags['client-select'];
const targetOrg = flags['target-org'];

const { ldpServerId, ldpServerToken } = await PreviewUtils.initializePreviewConnection(targetOrg);

logger.debug('Determining the next available port for Local Dev Server');
const serverPorts = await PreviewUtils.getNextAvailablePorts();
logger.debug(`Next available ports are http=${serverPorts.httpPort} , https=${serverPorts.httpsPort}`);

logger.debug('Determining Local Dev Server url');
const ldpServerUrl = PreviewUtils.generateWebSocketUrlForLocalDevServer(Platform.desktop, serverPorts, logger);
logger.debug(`Local Dev Server url is ${ldpServerUrl}`);

const namespacePaths = await ComponentUtils.getNamespacePaths(project);
const componentPaths = await ComponentUtils.getAllComponentPaths(namespacePaths);
if (!componentPaths) {
Expand All @@ -63,48 +87,49 @@ export default class LightningDevComponent extends SfCommand<void> {
return undefined;
}

const componentName = path.basename(componentPath);
const label = ComponentUtils.componentNameToTitleCase(componentName);
const name = path.basename(componentPath);
const label = ComponentUtils.componentNameToTitleCase(name);

return {
name: componentName,
name,
label: xml.LightningComponentBundle.masterLabel ?? label,
description: xml.LightningComponentBundle.description ?? '',
};
})
)
).filter((component) => !!component);

let name = flags.name;
if (!flags['client-select']) {
if (name) {
if (!clientSelect) {
if (componentName) {
// validate that the component exists before launching the server
const match = components.find((component) => name === component.name || name === component.label);
const match = components.find(
(component) => componentName === component.name || componentName === component.label
);
if (!match) {
throw new Error(messages.getMessage('error.component-not-found', [name]));
throw new Error(messages.getMessage('error.component-not-found', [componentName]));
}

name = match.name;
componentName = match.name;
} else {
// prompt the user for a name if one was not provided
name = await PromptUtils.promptUserToSelectComponent(components);
if (!name) {
componentName = await PromptUtils.promptUserToSelectComponent(components);
if (!componentName) {
throw new Error(messages.getMessage('error.component'));
}
}
}

const dirname = path.dirname(url.fileURLToPath(import.meta.url));
const rootDir = path.resolve(dirname, '../../../..');
const port = parseInt(process.env.PORT ?? '3000', 10);

await cmpDev({
rootDir,
mode: 'dev',
port,
name: name ? `c/${name}` : undefined,
namespacePaths,
open: process.env.OPEN_BROWSER === 'false' ? false : true,
});
await startLWCServer(logger, sfdxProjectRootPath, ldpServerToken, Platform.desktop, serverPorts);

const targetOrgArg = PreviewUtils.getTargetOrgFromArguments(this.argv);
const launchArguments = PreviewUtils.generateComponentPreviewLaunchArguments(
ldpServerUrl,
ldpServerId,
componentName,
targetOrgArg
);

// Open the browser and navigate to the right page
await this.config.runCommand('org:open', launchArguments);
Copy link
Collaborator

Choose a reason for hiding this comment

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

@bpbuch I need to be able to get the proper URL and iframe it inside of the VSCode webview. Right now it works because I know the URL is localhost, but with this change how will I be able to get the right URL and set that into the iframe?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also we still want a way to not automatically launch the browser when running the command in VSCode / Code Builder (i.e. we only want to open the preview panel)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

how will I be able to get the right URL and set that into the iframe?

We will need to follow up this PR with a programatic way to start the local dev server and access the target org URL(containing the local dev server URL and token). Alternatively, the VS Code extension could directly configure, launch, and manage the local dev server without a dependency on the CLI.

we still want a way to not automatically launch the browser

For the CLI use case, I think launching the browser is almost required because the local dev server port and user token are resolved when the command is invoked. For VS Code, the programmatic access should solve this problem as well.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I've added a followup work item for @Shinoni to take a look at

}
}
4 changes: 3 additions & 1 deletion src/lwc-dev-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ async function createLWCServerConfig(
const { namespace } = projectJson;

// e.g. lwc folders in force-app/main/default/lwc, package-dir/lwc
const namespacePaths = (await Promise.all(packageDirs.map((dir) => glob(`${dir.fullPath}/**/lwc`, { absolute: true })))).flat();
const namespacePaths = (
await Promise.all(packageDirs.map((dir) => glob(`${dir.fullPath}/**/lwc`, { absolute: true })))
).flat();

const ports = serverPorts ??
(await ConfigUtils.getLocalDevServerPorts()) ?? {
Expand Down
100 changes: 99 additions & 1 deletion src/shared/previewUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Connection, Logger, Messages } from '@salesforce/core';
import { Connection, Logger, Messages, Org } from '@salesforce/core';
import {
AndroidDeviceManager,
AppleDeviceManager,
Expand All @@ -33,8 +33,15 @@ import { PromptUtils } from './promptUtils.js';

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('@salesforce/plugin-lightning-dev', 'lightning.dev.app');
const sharedMessages = Messages.loadMessages('@salesforce/plugin-lightning-dev', 'shared.utils');
const DevPreviewAuraMode = 'DEVPREVIEW';

export type PreviewConnection = {
connection: Connection;
ldpServerId: string;
ldpServerToken: string;
};

export class PreviewUtils {
public static generateWebSocketUrlForLocalDevServer(
platform: string,
Expand Down Expand Up @@ -139,6 +146,37 @@ export class PreviewUtils {
}
}

/**
* Extracts the target org from command line arguments.
*
* There are various ways to pass in a target org (as an alias, as a username, etc).
* We could have LightningPreviewApp parse its --target-org flag which will be resolved
* to an Org object (see https://github.com/forcedotcom/sfdx-core/blob/main/src/org/org.ts)
* then write a bunch of code to look at this Org object to try to determine whether
* it was initialized using Alias, Username, etc. and get a string representation of the
* org to be forwarded to OrgOpenCommand.
*
* Or we could simply look at the raw arguments passed to the LightningPreviewApp command,
* find the raw value for --target-org flag and forward that raw value to OrgOpenCommand.
* The OrgOpenCommand will then parse the raw value automatically. If the value is
* valid then OrgOpenCommand will consume it and continue. And if the value is invalid then
* OrgOpenCommand simply throws an error which will get bubbled up to LightningPreviewApp.
*
* Here we've chosen the second approach.
*
* @param args - Array of command line arguments
* @returns The target org identifier if found, undefined otherwise
*/
public static getTargetOrgFromArguments(args: string[]): string | undefined {
const idx = args.findIndex((item) => item.toLowerCase() === '-o' || item.toLowerCase() === '--target-org');
let targetOrg: string | undefined;
if (idx >= 0 && idx < args.length - 1) {
targetOrg = args[idx + 1];
}

return targetOrg;
}

/**
* Generates the proper set of arguments to be used for launching desktop browser and navigating to the right location.
*
Expand Down Expand Up @@ -176,6 +214,38 @@ export class PreviewUtils {
return launchArguments;
}

/**
* Generates the proper set of arguments to be used for launching a component preview in the browser.
*
* @param ldpServerUrl The URL for the local dev server
* @param ldpServerId Record ID for the identity token
* @param componentName The name of the component to preview
* @param targetOrg An optional org id
* @returns Array of arguments to be used by Org:Open command for launching the component preview
*/
public static generateComponentPreviewLaunchArguments(
ldpServerUrl: string,
ldpServerId: string,
componentName?: string,
targetOrg?: string
): string[] {
let appPath = `lwr/application/e/devpreview/ai/${encodeURIComponent(
'localdev%2Fpreview'
)}?ldpServerUrl=${ldpServerUrl}&ldpServerId=${ldpServerId}`;
if (componentName) {
// TODO: support other namespaces
appPath += `&specifier=c/${componentName}`;
}

const launchArguments = ['--path', appPath];

if (targetOrg) {
launchArguments.push('--target-org', targetOrg);
}

return launchArguments;
}

/**
* Generates the proper set of arguments to be used for launching a mobile app with custom launch arguments.
*
Expand Down Expand Up @@ -324,6 +394,34 @@ export class PreviewUtils {
});
}

public static async initializePreviewConnection(targetOrg: Org): Promise<PreviewConnection> {
const connection = targetOrg.getConnection(undefined);
const username = connection.getUsername();
if (!username) {
return Promise.reject(new Error(sharedMessages.getMessage('error.username')));
}

const localDevEnabled = await OrgUtils.isLocalDevEnabled(connection);
if (!localDevEnabled) {
return Promise.reject(new Error(sharedMessages.getMessage('error.localdev.not.enabled')));
}

OrgUtils.ensureMatchingAPIVersion(connection);

const appServerIdentity = await PreviewUtils.getOrCreateAppServerIdentity(connection);
const ldpServerToken = appServerIdentity.identityToken;
const ldpServerId = appServerIdentity.usernameToServerEntityIdMap[username];
if (!ldpServerId) {
return Promise.reject(new Error(sharedMessages.getMessage('error.identitydata.entityid')));
}

return {
connection,
ldpServerId,
ldpServerToken,
};
}

public static async getOrCreateAppServerIdentity(connection: Connection): Promise<LocalWebServerIdentityData> {
const username = connection.getUsername()!;

Expand Down
Loading
Loading