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
2 changes: 1 addition & 1 deletion command-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"command": "lightning:dev:site",
"flagAliases": [],
"flagChars": ["l", "n", "o"],
"flags": ["flags-dir", "get-latest", "guest", "name", "target-org"],
"flags": ["flags-dir", "get-latest", "guest", "name", "target-org", "ssr"],
"plugin": "@salesforce/plugin-lightning-dev"
}
]
16 changes: 0 additions & 16 deletions messages/lightning.dev.app.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,22 +31,6 @@ Type of device to display the app preview.

ID of the mobile device to display the preview if device type is set to `ios` or `android`. The default value is the ID of the first available mobile device.

# error.username

Org must have a valid user

# error.identitydata

Couldn't find identity data while generating preview arguments

# error.identitydata.entityid

Couldn't find entity ID while generating preview arguments

# error.no-project

This command is required to run from within a Salesforce project directory. %s

# error.fetching.app-id

Unable to determine App Id for %s
Expand Down
4 changes: 4 additions & 0 deletions messages/lightning.dev.site.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ Download the latest version of the specified site from your org, instead of usin

Preview the site as a guest user (rather than an authenticated user).

# flags.ssr.summary

Preview the SSR bundle

# examples

- Select a site to preview from the org "myOrg":
Expand Down
16 changes: 16 additions & 0 deletions messages/shared.utils.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,19 @@ Your org is on API version %s, but this version of the CLI plugin supports API v
# error.org.api-mismatch.remediation

To use the plugin with this org, you can reinstall or update the plugin using the "%s" tag. For example: "sf plugins install %s".

# error.username

Org must have a valid user

# error.identitydata

Couldn't find identity data while generating preview arguments

# error.identitydata.entityid

Couldn't find entity ID while generating preview arguments

# error.no-project

This command is required to run from within a Salesforce project directory. %s
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"lightning-base-components": "1.27.2-alpha",
"lwc": "~8.20.1",
"node-fetch": "^3.3.2",
"open": "^10.1.0",
"xml2js": "^0.6.2"
},
"devDependencies": {
Expand Down
8 changes: 4 additions & 4 deletions src/commands/lightning/dev/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,18 +74,18 @@ export default class LightningDevApp extends SfCommand<void> {
try {
sfdxProjectRootPath = await SfProject.resolveProjectPath();
} catch (error) {
return Promise.reject(new Error(messages.getMessage('error.no-project', [(error as Error)?.message ?? ''])));
throw new Error(sharedMessages.getMessage('error.no-project', [(error as Error)?.message ?? '']));
}

const connection = targetOrg.getConnection(undefined);
const username = connection.getUsername();
if (!username) {
return Promise.reject(new Error(messages.getMessage('error.username')));
throw 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')));
throw new Error(sharedMessages.getMessage('error.localdev.not.enabled'));
}

OrgUtils.ensureMatchingAPIVersion(connection);
Expand All @@ -97,7 +97,7 @@ export default class LightningDevApp extends SfCommand<void> {
const ldpServerToken = appServerIdentity.identityToken;
const ldpServerId = appServerIdentity.usernameToServerEntityIdMap[username];
if (!ldpServerId) {
return Promise.reject(new Error(messages.getMessage('error.identitydata.entityid')));
throw new Error(sharedMessages.getMessage('error.identitydata.entityid'));
}

const appId = await PreviewUtils.getLightningExperienceAppId(connection, appName, logger);
Expand Down
162 changes: 111 additions & 51 deletions src/commands/lightning/dev/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@
*/
import fs from 'node:fs';
import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
import { Messages } from '@salesforce/core';
import { Connection, Logger, Messages, SfProject } from '@salesforce/core';
import { Platform } from '@salesforce/lwc-dev-mobile-core';
import { expDev, SitesLocalDevOptions, setupDev } from '@lwrjs/api';
import open from 'open';
import { OrgUtils } from '../../../shared/orgUtils.js';
import { PromptUtils } from '../../../shared/promptUtils.js';
import { ExperienceSite } from '../../../shared/experience/expSite.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.site');
Expand All @@ -36,6 +40,10 @@ export default class LightningDevSite extends SfCommand<void> {
summary: messages.getMessage('flags.guest.summary'),
default: false,
}),
ssr: Flags.boolean({
summary: messages.getMessage('flags.ssr.summary'),
default: false,
}),
};

public async run(): Promise<void> {
Expand All @@ -45,6 +53,7 @@ export default class LightningDevSite extends SfCommand<void> {
const org = flags['target-org'];
const getLatest = flags['get-latest'];
const guest = flags.guest;
const ssr = flags.ssr;
let siteName = flags.name;

const connection = org.getConnection(undefined);
Expand All @@ -63,61 +72,112 @@ export default class LightningDevSite extends SfCommand<void> {
}

const selectedSite = new ExperienceSite(org, siteName);
let siteZip: string | undefined;

// If the site is not setup / is not based on the current release / or get-latest is requested ->
// generate and download a new site bundle from the org based on latest builder metadata
if (!selectedSite.isSiteSetup() || getLatest) {
const startTime = Date.now();
this.log(`[local-dev] Initializing: ${siteName}`);
this.spinner.start('[local-dev] Downloading site (this may take a few minutes)');
siteZip = await selectedSite.downloadSite();

// delete oldSitePath recursive
const oldSitePath = selectedSite.getExtractDirectory();
if (fs.existsSync(oldSitePath)) {
fs.rmSync(oldSitePath, { recursive: true });
}
const endTime = Date.now();
const duration = (endTime - startTime) / 1000; // Convert to seconds
this.spinner.stop('done.');
this.log(`[local-dev] Site setup completed in ${duration.toFixed(2)} seconds.`);
}

this.log(`[local-dev] launching browser preview for: ${siteName}`);

// Establish a valid access token for this site
const authToken = guest ? '' : await selectedSite.setupAuth();

// Start the dev server
const port = parseInt(process.env.PORT ?? '3000', 10);

// Internal vs external mode
const internalProject = !fs.existsSync('sfdx-project.json') && fs.existsSync('lwr.config.json');
const logLevel = process.env.LOG_LEVEL ?? 'error';

const startupParams: SitesLocalDevOptions = {
sfCLI: !internalProject,
authToken,
open: process.env.OPEN_BROWSER === 'false' ? false : true,
port,
logLevel,
mode: 'dev',
siteZip,
siteDir: selectedSite.getSiteDirectory(),
};

// Environment variable used to setup the site rather than setup & start server
if (process.env.SETUP_ONLY === 'true') {
await setupDev(startupParams);
this.log('[local-dev] setup complete!');
} else {
await expDev(startupParams);
this.log('[local-dev] watching for file changes... (CTRL-C to stop)');
if (!ssr) {
return await this.openPreviewUrl(selectedSite, connection);
}
await this.serveSSRSite(selectedSite, getLatest, siteName, guest);
} catch (e) {
this.spinner.stop('failed.');
this.log('Local Development setup failed', e);
}
}

private async serveSSRSite(
selectedSite: ExperienceSite,
getLatest: boolean,
siteName: string,
guest: boolean
): Promise<void> {
let siteZip: string | undefined;

// If the site is not setup / is not based on the current release / or get-latest is requested ->
// generate and download a new site bundle from the org based on latest builder metadata
if (!selectedSite.isSiteSetup() || getLatest) {
const startTime = Date.now();
this.log(`[local-dev] Initializing: ${siteName}`);
this.spinner.start('[local-dev] Downloading site (this may take a few minutes)');
siteZip = await selectedSite.downloadSite();

// delete oldSitePath recursive
const oldSitePath = selectedSite.getExtractDirectory();
if (fs.existsSync(oldSitePath)) {
fs.rmSync(oldSitePath, { recursive: true });
}
const endTime = Date.now();
const duration = (endTime - startTime) / 1000; // Convert to seconds
this.spinner.stop('done.');
this.log(`[local-dev] Site setup completed in ${duration.toFixed(2)} seconds.`);
}

this.log(`[local-dev] launching browser preview for: ${siteName}`);

// Establish a valid access token for this site
const authToken = guest ? '' : await selectedSite.setupAuth();

// Start the dev server
const port = parseInt(process.env.PORT ?? '3000', 10);

// Internal vs external mode
const internalProject = !fs.existsSync('sfdx-project.json') && fs.existsSync('lwr.config.json');
const logLevel = process.env.LOG_LEVEL ?? 'error';

const startupParams: SitesLocalDevOptions = {
sfCLI: !internalProject,
authToken,
open: process.env.OPEN_BROWSER === 'false' ? false : true,
port,
logLevel,
mode: 'dev',
siteZip,
siteDir: selectedSite.getSiteDirectory(),
};

// Environment variable used to setup the site rather than setup & start server
if (process.env.SETUP_ONLY === 'true') {
await setupDev(startupParams);
this.log('[local-dev] setup complete!');
} else {
await expDev(startupParams);
this.log('[local-dev] watching for file changes... (CTRL-C to stop)');
}
}

private async openPreviewUrl(selectedSite: ExperienceSite, connection: Connection): Promise<void> {
let sfdxProjectRootPath = '';
try {
sfdxProjectRootPath = await SfProject.resolveProjectPath();
} catch (error) {
throw new Error(sharedMessages.getMessage('error.no-project', [(error as Error)?.message ?? '']));
}
const previewUrl = await selectedSite.getPreviewUrl();
const username = connection.getUsername();
if (!username) {
throw new Error(sharedMessages.getMessage('error.username'));
}

this.log('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'));
}

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

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

const logger = await Logger.child(this.ctor.name);
await startLWCServer(logger, sfdxProjectRootPath, ldpServerToken, Platform.desktop, serverPorts);
const url = new URL(previewUrl);
url.searchParams.set('aura.lwcDevServerUrl', ldpServerUrl);
url.searchParams.set('aura.lwcDevServerId', ldpServerId);
url.searchParams.set('lwc.mode', 'dev');
await open(url.toString());
}
}
41 changes: 41 additions & 0 deletions src/shared/experience/expSite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,47 @@ export class ExperienceSite {
return retVal;
}

public async getPreviewUrl(): Promise<string> {
// Get the community ID
const communityId = await this.getNetworkId();
const conn = this.org.getConnection();
const accessToken = conn.accessToken;
const instanceUrl = conn.instanceUrl;

if (!accessToken) {
throw new SfError(`Invalid access token, unable to get preview URL for: ${this.siteDisplayName}`);
}

try {
// Call the communities API to get the preview URL
const apiUrl = `${instanceUrl}/services/data/v64.0/connect/communities/${communityId}/preview-url/pages/Home`;
const response = await axios.get<{ previewUrl: string }>(apiUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});

if (response.data?.previewUrl) {
return response.data.previewUrl;
} else {
throw new SfError(`Invalid response from communities API for site: ${this.siteDisplayName}`);
}
} catch (error) {
// Handle axios errors
if (axios.isAxiosError(error)) {
if (error.response) {
// Server responded with non-200 status
throw new SfError(
`Failed to get preview URL: Server responded with status ${error.response.status} - ${error.response.statusText}`
);
} else if (error.request) {
// Request was made but no response received
throw new SfError('Failed to get preview URL: No response received from server');
}
}
throw new SfError(`Failed to get preview URL for site: ${this.siteDisplayName}`);
}
}
/**
* Generate a site bundle on demand and download it
*
Expand Down
4 changes: 2 additions & 2 deletions src/shared/previewUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,13 @@ export class PreviewUtils {
const userConfiguredPorts = await ConfigUtils.getLocalDevServerPorts();

if (userConfiguredPorts) {
return Promise.resolve(userConfiguredPorts);
return userConfiguredPorts;
}

const httpPort = await this.doGetNextAvailablePort(LOCAL_DEV_SERVER_DEFAULT_HTTP_PORT);
const httpsPort = await this.doGetNextAvailablePort(httpPort + 1);

return Promise.resolve({ httpPort, httpsPort });
return { httpPort, httpsPort };
}

/**
Expand Down
2 changes: 1 addition & 1 deletion test/commands/lightning/dev/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ describe('lightning dev app', () => {
$$.SANDBOX.stub(Connection.prototype, 'getUsername').returns(undefined);
await MockedLightningPreviewApp.run(['--name', 'blah', '-o', testOrgData.username, '-t', Platform.desktop]);
} catch (err) {
expect(err).to.be.an('error').with.property('message', messages.getMessage('error.username'));
expect(err).to.be.an('error').with.property('message', sharedMessages.getMessage('error.username'));
}
});

Expand Down
Loading
Loading