Skip to content

Commit b8a8844

Browse files
authored
feat: launch site preview @W-18283821 (#449)
* feat: launch site preview @W-18283821 * fix: yarn * fix: address feedback
1 parent 374e388 commit b8a8844

File tree

11 files changed

+240
-75
lines changed

11 files changed

+240
-75
lines changed

command-snapshot.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"command": "lightning:dev:site",
2121
"flagAliases": [],
2222
"flagChars": ["l", "n", "o"],
23-
"flags": ["flags-dir", "get-latest", "guest", "name", "target-org"],
23+
"flags": ["flags-dir", "get-latest", "guest", "name", "target-org", "ssr"],
2424
"plugin": "@salesforce/plugin-lightning-dev"
2525
}
2626
]

messages/lightning.dev.app.md

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,22 +31,6 @@ Type of device to display the app preview.
3131

3232
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.
3333

34-
# error.username
35-
36-
Org must have a valid user
37-
38-
# error.identitydata
39-
40-
Couldn't find identity data while generating preview arguments
41-
42-
# error.identitydata.entityid
43-
44-
Couldn't find entity ID while generating preview arguments
45-
46-
# error.no-project
47-
48-
This command is required to run from within a Salesforce project directory. %s
49-
5034
# error.fetching.app-id
5135

5236
Unable to determine App Id for %s

messages/lightning.dev.site.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ Download the latest version of the specified site from your org, instead of usin
2929

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

32+
# flags.ssr.summary
33+
34+
Preview the SSR bundle
35+
3236
# examples
3337

3438
- Select a site to preview from the org "myOrg":

messages/shared.utils.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,19 @@ Your org is on API version %s, but this version of the CLI plugin supports API v
3737
# error.org.api-mismatch.remediation
3838

3939
To use the plugin with this org, you can reinstall or update the plugin using the "%s" tag. For example: "sf plugins install %s".
40+
41+
# error.username
42+
43+
Org must have a valid user
44+
45+
# error.identitydata
46+
47+
Couldn't find identity data while generating preview arguments
48+
49+
# error.identitydata.entityid
50+
51+
Couldn't find entity ID while generating preview arguments
52+
53+
# error.no-project
54+
55+
This command is required to run from within a Salesforce project directory. %s

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"lightning-base-components": "1.27.2-alpha",
2121
"lwc": "~8.20.1",
2222
"node-fetch": "^3.3.2",
23+
"open": "^10.1.0",
2324
"xml2js": "^0.6.2"
2425
},
2526
"devDependencies": {

src/commands/lightning/dev/app.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,18 +74,18 @@ export default class LightningDevApp extends SfCommand<void> {
7474
try {
7575
sfdxProjectRootPath = await SfProject.resolveProjectPath();
7676
} catch (error) {
77-
return Promise.reject(new Error(messages.getMessage('error.no-project', [(error as Error)?.message ?? ''])));
77+
throw new Error(sharedMessages.getMessage('error.no-project', [(error as Error)?.message ?? '']));
7878
}
7979

8080
const connection = targetOrg.getConnection(undefined);
8181
const username = connection.getUsername();
8282
if (!username) {
83-
return Promise.reject(new Error(messages.getMessage('error.username')));
83+
throw new Error(sharedMessages.getMessage('error.username'));
8484
}
8585

8686
const localDevEnabled = await OrgUtils.isLocalDevEnabled(connection);
8787
if (!localDevEnabled) {
88-
return Promise.reject(new Error(sharedMessages.getMessage('error.localdev.not.enabled')));
88+
throw new Error(sharedMessages.getMessage('error.localdev.not.enabled'));
8989
}
9090

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

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

src/commands/lightning/dev/site.ts

Lines changed: 111 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@
66
*/
77
import fs from 'node:fs';
88
import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
9-
import { Messages } from '@salesforce/core';
9+
import { Connection, Logger, Messages, SfProject } from '@salesforce/core';
10+
import { Platform } from '@salesforce/lwc-dev-mobile-core';
1011
import { expDev, SitesLocalDevOptions, setupDev } from '@lwrjs/api';
12+
import open from 'open';
1113
import { OrgUtils } from '../../../shared/orgUtils.js';
1214
import { PromptUtils } from '../../../shared/promptUtils.js';
1315
import { ExperienceSite } from '../../../shared/experience/expSite.js';
16+
import { PreviewUtils } from '../../../shared/previewUtils.js';
17+
import { startLWCServer } from '../../../lwc-dev-server/index.js';
1418

1519
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
1620
const messages = Messages.loadMessages('@salesforce/plugin-lightning-dev', 'lightning.dev.site');
@@ -36,6 +40,10 @@ export default class LightningDevSite extends SfCommand<void> {
3640
summary: messages.getMessage('flags.guest.summary'),
3741
default: false,
3842
}),
43+
ssr: Flags.boolean({
44+
summary: messages.getMessage('flags.ssr.summary'),
45+
default: false,
46+
}),
3947
};
4048

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

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

6574
const selectedSite = new ExperienceSite(org, siteName);
66-
let siteZip: string | undefined;
67-
68-
// If the site is not setup / is not based on the current release / or get-latest is requested ->
69-
// generate and download a new site bundle from the org based on latest builder metadata
70-
if (!selectedSite.isSiteSetup() || getLatest) {
71-
const startTime = Date.now();
72-
this.log(`[local-dev] Initializing: ${siteName}`);
73-
this.spinner.start('[local-dev] Downloading site (this may take a few minutes)');
74-
siteZip = await selectedSite.downloadSite();
75-
76-
// delete oldSitePath recursive
77-
const oldSitePath = selectedSite.getExtractDirectory();
78-
if (fs.existsSync(oldSitePath)) {
79-
fs.rmSync(oldSitePath, { recursive: true });
80-
}
81-
const endTime = Date.now();
82-
const duration = (endTime - startTime) / 1000; // Convert to seconds
83-
this.spinner.stop('done.');
84-
this.log(`[local-dev] Site setup completed in ${duration.toFixed(2)} seconds.`);
85-
}
8675

87-
this.log(`[local-dev] launching browser preview for: ${siteName}`);
88-
89-
// Establish a valid access token for this site
90-
const authToken = guest ? '' : await selectedSite.setupAuth();
91-
92-
// Start the dev server
93-
const port = parseInt(process.env.PORT ?? '3000', 10);
94-
95-
// Internal vs external mode
96-
const internalProject = !fs.existsSync('sfdx-project.json') && fs.existsSync('lwr.config.json');
97-
const logLevel = process.env.LOG_LEVEL ?? 'error';
98-
99-
const startupParams: SitesLocalDevOptions = {
100-
sfCLI: !internalProject,
101-
authToken,
102-
open: process.env.OPEN_BROWSER === 'false' ? false : true,
103-
port,
104-
logLevel,
105-
mode: 'dev',
106-
siteZip,
107-
siteDir: selectedSite.getSiteDirectory(),
108-
};
109-
110-
// Environment variable used to setup the site rather than setup & start server
111-
if (process.env.SETUP_ONLY === 'true') {
112-
await setupDev(startupParams);
113-
this.log('[local-dev] setup complete!');
114-
} else {
115-
await expDev(startupParams);
116-
this.log('[local-dev] watching for file changes... (CTRL-C to stop)');
76+
if (!ssr) {
77+
return await this.openPreviewUrl(selectedSite, connection);
11778
}
79+
await this.serveSSRSite(selectedSite, getLatest, siteName, guest);
11880
} catch (e) {
11981
this.spinner.stop('failed.');
12082
this.log('Local Development setup failed', e);
12183
}
12284
}
85+
86+
private async serveSSRSite(
87+
selectedSite: ExperienceSite,
88+
getLatest: boolean,
89+
siteName: string,
90+
guest: boolean
91+
): Promise<void> {
92+
let siteZip: string | undefined;
93+
94+
// If the site is not setup / is not based on the current release / or get-latest is requested ->
95+
// generate and download a new site bundle from the org based on latest builder metadata
96+
if (!selectedSite.isSiteSetup() || getLatest) {
97+
const startTime = Date.now();
98+
this.log(`[local-dev] Initializing: ${siteName}`);
99+
this.spinner.start('[local-dev] Downloading site (this may take a few minutes)');
100+
siteZip = await selectedSite.downloadSite();
101+
102+
// delete oldSitePath recursive
103+
const oldSitePath = selectedSite.getExtractDirectory();
104+
if (fs.existsSync(oldSitePath)) {
105+
fs.rmSync(oldSitePath, { recursive: true });
106+
}
107+
const endTime = Date.now();
108+
const duration = (endTime - startTime) / 1000; // Convert to seconds
109+
this.spinner.stop('done.');
110+
this.log(`[local-dev] Site setup completed in ${duration.toFixed(2)} seconds.`);
111+
}
112+
113+
this.log(`[local-dev] launching browser preview for: ${siteName}`);
114+
115+
// Establish a valid access token for this site
116+
const authToken = guest ? '' : await selectedSite.setupAuth();
117+
118+
// Start the dev server
119+
const port = parseInt(process.env.PORT ?? '3000', 10);
120+
121+
// Internal vs external mode
122+
const internalProject = !fs.existsSync('sfdx-project.json') && fs.existsSync('lwr.config.json');
123+
const logLevel = process.env.LOG_LEVEL ?? 'error';
124+
125+
const startupParams: SitesLocalDevOptions = {
126+
sfCLI: !internalProject,
127+
authToken,
128+
open: process.env.OPEN_BROWSER === 'false' ? false : true,
129+
port,
130+
logLevel,
131+
mode: 'dev',
132+
siteZip,
133+
siteDir: selectedSite.getSiteDirectory(),
134+
};
135+
136+
// Environment variable used to setup the site rather than setup & start server
137+
if (process.env.SETUP_ONLY === 'true') {
138+
await setupDev(startupParams);
139+
this.log('[local-dev] setup complete!');
140+
} else {
141+
await expDev(startupParams);
142+
this.log('[local-dev] watching for file changes... (CTRL-C to stop)');
143+
}
144+
}
145+
146+
private async openPreviewUrl(selectedSite: ExperienceSite, connection: Connection): Promise<void> {
147+
let sfdxProjectRootPath = '';
148+
try {
149+
sfdxProjectRootPath = await SfProject.resolveProjectPath();
150+
} catch (error) {
151+
throw new Error(sharedMessages.getMessage('error.no-project', [(error as Error)?.message ?? '']));
152+
}
153+
const previewUrl = await selectedSite.getPreviewUrl();
154+
const username = connection.getUsername();
155+
if (!username) {
156+
throw new Error(sharedMessages.getMessage('error.username'));
157+
}
158+
159+
this.log('Configuring local web server identity');
160+
const appServerIdentity = await PreviewUtils.getOrCreateAppServerIdentity(connection);
161+
const ldpServerToken = appServerIdentity.identityToken;
162+
const ldpServerId = appServerIdentity.usernameToServerEntityIdMap[username];
163+
if (!ldpServerId) {
164+
throw new Error(sharedMessages.getMessage('error.identitydata.entityid'));
165+
}
166+
167+
this.log('Determining the next available port for Local Dev Server');
168+
const serverPorts = await PreviewUtils.getNextAvailablePorts();
169+
this.log(`Next available ports are http=${serverPorts.httpPort} , https=${serverPorts.httpsPort}`);
170+
171+
this.log('Determining Local Dev Server url');
172+
const ldpServerUrl = PreviewUtils.generateWebSocketUrlForLocalDevServer(Platform.desktop, serverPorts);
173+
this.log(`Local Dev Server url is ${ldpServerUrl}`);
174+
175+
const logger = await Logger.child(this.ctor.name);
176+
await startLWCServer(logger, sfdxProjectRootPath, ldpServerToken, Platform.desktop, serverPorts);
177+
const url = new URL(previewUrl);
178+
url.searchParams.set('aura.lwcDevServerUrl', ldpServerUrl);
179+
url.searchParams.set('aura.lwcDevServerId', ldpServerId);
180+
url.searchParams.set('lwc.mode', 'dev');
181+
await open(url.toString());
182+
}
123183
}

src/shared/experience/expSite.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,47 @@ export class ExperienceSite {
207207
return retVal;
208208
}
209209

210+
public async getPreviewUrl(): Promise<string> {
211+
// Get the community ID
212+
const communityId = await this.getNetworkId();
213+
const conn = this.org.getConnection();
214+
const accessToken = conn.accessToken;
215+
const instanceUrl = conn.instanceUrl;
216+
217+
if (!accessToken) {
218+
throw new SfError(`Invalid access token, unable to get preview URL for: ${this.siteDisplayName}`);
219+
}
220+
221+
try {
222+
// Call the communities API to get the preview URL
223+
const apiUrl = `${instanceUrl}/services/data/v64.0/connect/communities/${communityId}/preview-url/pages/Home`;
224+
const response = await axios.get<{ previewUrl: string }>(apiUrl, {
225+
headers: {
226+
Authorization: `Bearer ${accessToken}`,
227+
},
228+
});
229+
230+
if (response.data?.previewUrl) {
231+
return response.data.previewUrl;
232+
} else {
233+
throw new SfError(`Invalid response from communities API for site: ${this.siteDisplayName}`);
234+
}
235+
} catch (error) {
236+
// Handle axios errors
237+
if (axios.isAxiosError(error)) {
238+
if (error.response) {
239+
// Server responded with non-200 status
240+
throw new SfError(
241+
`Failed to get preview URL: Server responded with status ${error.response.status} - ${error.response.statusText}`
242+
);
243+
} else if (error.request) {
244+
// Request was made but no response received
245+
throw new SfError('Failed to get preview URL: No response received from server');
246+
}
247+
}
248+
throw new SfError(`Failed to get preview URL for site: ${this.siteDisplayName}`);
249+
}
250+
}
210251
/**
211252
* Generate a site bundle on demand and download it
212253
*

src/shared/previewUtils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,13 @@ export class PreviewUtils {
6262
const userConfiguredPorts = await ConfigUtils.getLocalDevServerPorts();
6363

6464
if (userConfiguredPorts) {
65-
return Promise.resolve(userConfiguredPorts);
65+
return userConfiguredPorts;
6666
}
6767

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

71-
return Promise.resolve({ httpPort, httpsPort });
71+
return { httpPort, httpsPort };
7272
}
7373

7474
/**

test/commands/lightning/dev/app.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ describe('lightning dev app', () => {
134134
$$.SANDBOX.stub(Connection.prototype, 'getUsername').returns(undefined);
135135
await MockedLightningPreviewApp.run(['--name', 'blah', '-o', testOrgData.username, '-t', Platform.desktop]);
136136
} catch (err) {
137-
expect(err).to.be.an('error').with.property('message', messages.getMessage('error.username'));
137+
expect(err).to.be.an('error').with.property('message', sharedMessages.getMessage('error.username'));
138138
}
139139
});
140140

0 commit comments

Comments
 (0)