Skip to content

Commit 03fd833

Browse files
authored
feat: [sites] add get-latest cli parameter / update lwr -> v0.16.2 (includes latest local-dev fixes) (#311)
1 parent 5fdb5c0 commit 03fd833

File tree

10 files changed

+339
-347
lines changed

10 files changed

+339
-347
lines changed

LICENSE.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
Copyright (c) 2024, Salesforce.com, Inc.
1+
Copyright (c) 2025, Salesforce.com, Inc.
22
All rights reserved.
33

44
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

command-snapshot.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
"alias": [],
1212
"command": "lightning:dev:site",
1313
"flagAliases": [],
14-
"flagChars": ["n", "o"],
15-
"flags": ["flags-dir", "name", "target-org"],
14+
"flagChars": ["l", "n", "o"],
15+
"flags": ["flags-dir", "get-latest", "name", "target-org"],
1616
"plugin": "@salesforce/plugin-lightning-dev"
1717
}
1818
]

messages/lightning.dev.site.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# summary
22

3-
Preview an Experience Builder site locally and in real-time, without deploying it.
3+
[Beta] Preview an Experience Builder site locally and in real-time, without deploying it.
44

55
# description
66

@@ -21,7 +21,15 @@ For more considerations and limitations, see the Lightning Web Components Develo
2121

2222
Name of the Experience Builder site to preview. It has to match a site name from the current org.
2323

24+
# flags.get-latest.summary
25+
26+
Download the latest version of the specified site from your org, instead of using any local cache.
27+
2428
# examples
2529

30+
- Select a site to preview from the org "myOrg":
31+
<%= config.bin %> <%= command.id %> --target-org myOrg
2632
- Preview the site "Partner Central" from the org "myOrg":
2733
<%= config.bin %> <%= command.id %> --name "Partner Central" --target-org myOrg
34+
- Get and preview the latest version of the "Partner Central" site from the org "myOrg"
35+
<%= config.bin %> <%= command.id %> --name "Partner Central" --target-org myOrg --get-latest

messages/prompts.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# site.select
22

3-
Select a site
3+
Which Experience Cloud Site would you like to preview (Use arrow keys)
44

55
# site.confirm-update
66

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"@inquirer/select": "^2.4.7",
1010
"@lwc/lwc-dev-server": "^11.1.0",
1111
"@lwc/sfdc-lwc-compiler": "^11.1.0",
12-
"@lwrjs/api": "0.15.6",
12+
"@lwrjs/api": "0.16.2",
1313
"@oclif/core": "^4.1.0",
1414
"@salesforce/core": "^8.6.2",
1515
"@salesforce/kit": "^3.1.6",

src/commands/lightning/dev/site.ts

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,18 @@ export default class LightningDevSite extends SfCommand<void> {
2828
char: 'n',
2929
}),
3030
'target-org': Flags.requiredOrg(),
31+
'get-latest': Flags.boolean({
32+
summary: messages.getMessage('flags.get-latest.summary'),
33+
char: 'l',
34+
}),
3135
};
3236

3337
public async run(): Promise<void> {
3438
const { flags } = await this.parse(LightningDevSite);
3539

3640
try {
3741
const org = flags['target-org'];
42+
const getLatest = flags['get-latest'];
3843
let siteName = flags.name;
3944

4045
const connection = org.getConnection(undefined);
@@ -55,26 +60,27 @@ export default class LightningDevSite extends SfCommand<void> {
5560
const selectedSite = new ExperienceSite(org, siteName);
5661
let siteZip: string | undefined;
5762

58-
if (!selectedSite.isSiteSetup()) {
59-
this.log(`[local-dev] initializing: ${siteName}`);
63+
// If the site is not setup / is not based on the current release / or get-latest is requested ->
64+
// generate and download a new site bundle from the org based on latest builder metadata
65+
if (!selectedSite.isSiteSetup() || getLatest) {
66+
const startTime = Date.now();
67+
this.log(`[local-dev] Initializing: ${siteName}`);
68+
this.spinner.start('[local-dev] Downloading site (this may take a few minutes)');
6069
siteZip = await selectedSite.downloadSite();
61-
} else {
62-
// If local-dev is already setup, check if an updated site has been published to download
63-
const updateAvailable = await selectedSite.isUpdateAvailable();
64-
if (updateAvailable) {
65-
const shouldUpdate = await PromptUtils.promptUserToConfirmUpdate(siteName);
66-
if (shouldUpdate) {
67-
this.log(`[local-dev] updating: ${siteName}`);
68-
siteZip = await selectedSite.downloadSite();
69-
// delete oldSitePath recursive
70-
const oldSitePath = selectedSite.getExtractDirectory();
71-
if (fs.existsSync(oldSitePath)) {
72-
fs.rmdirSync(oldSitePath, { recursive: true });
73-
}
74-
}
70+
71+
// delete oldSitePath recursive
72+
const oldSitePath = selectedSite.getExtractDirectory();
73+
if (fs.existsSync(oldSitePath)) {
74+
fs.rmSync(oldSitePath, { recursive: true });
7575
}
76+
const endTime = Date.now();
77+
const duration = (endTime - startTime) / 1000; // Convert to seconds
78+
this.spinner.stop('done.');
79+
this.log(`[local-dev] Site setup completed in ${duration.toFixed(2)} seconds.`);
7680
}
7781

82+
this.log(`[local-dev] launching browser preview for: ${siteName}`);
83+
7884
// Establish a valid access token for this site
7985
const authToken = await selectedSite.setupAuth();
8086

@@ -94,10 +100,13 @@ export default class LightningDevSite extends SfCommand<void> {
94100
// Environment variable used to setup the site rather than setup & start server
95101
if (process.env.SETUP_ONLY === 'true') {
96102
await setupDev(startupParams);
103+
this.log('[local-dev] setup complete!');
97104
} else {
98105
await expDev(startupParams);
106+
this.log('[local-dev] watching for file changes... (CTRL-C to stop)');
99107
}
100108
} catch (e) {
109+
this.spinner.stop('failed.');
101110
this.log('Local Development setup failed', e);
102111
}
103112
}

src/shared/experience/expSite.ts

Lines changed: 59 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import axios from 'axios';
1212
export type SiteMetadata = {
1313
bundleName: string;
1414
bundleLastModified: string;
15+
coreVersion: string;
1516
};
1617

1718
export type SiteMetadataCache = {
@@ -41,44 +42,6 @@ export class ExperienceSite {
4142
this.siteName = this.siteName.replace(/[^a-zA-Z0-9]/g, '_');
4243
}
4344

44-
/**
45-
* Get an experience site bundle by site name.
46-
*
47-
* @param conn - Salesforce connection object.
48-
* @param siteName - The name of the experience site.
49-
* @returns - The experience site.
50-
*
51-
* @param siteName
52-
* @returns
53-
*/
54-
public static getLocalExpSite(siteName: string): ExperienceSite {
55-
const siteJsonPath = path.join('.localdev', siteName.trim().replace(' ', '_'), 'site.json');
56-
const siteJson = fs.readFileSync(siteJsonPath, 'utf8');
57-
const site = JSON.parse(siteJson) as ExperienceSite;
58-
return site;
59-
}
60-
61-
/**
62-
* Fetches all experience site bundles that are published to MRT.
63-
*
64-
* @param {Connection} conn - Salesforce connection object.
65-
* @returns {Promise<ExperienceSite[]>} - List of experience sites.
66-
*/
67-
public static async getAllPublishedExpSites(org: Org): Promise<ExperienceSite[]> {
68-
const result = await org
69-
.getConnection()
70-
.query<{ Id: string; Name: string; LastModifiedDate: string }>(
71-
"SELECT Id, Name, LastModifiedDate FROM StaticResource WHERE Name LIKE 'MRT%_'"
72-
);
73-
74-
// Example of creating ExperienceSite instances
75-
const experienceSites: ExperienceSite[] = result.records.map(
76-
(record) => new ExperienceSite(org, getSiteNameFromStaticResource(record.Name))
77-
);
78-
79-
return experienceSites;
80-
}
81-
8245
/**
8346
* Fetches all current experience sites
8447
*
@@ -135,7 +98,10 @@ export class ExperienceSite {
13598

13699
// Is the site extracted locally
137100
public isSiteSetup(): boolean {
138-
return fs.existsSync(path.join(this.getExtractDirectory(), 'ssr.js'));
101+
if (fs.existsSync(path.join(this.getExtractDirectory(), 'ssr.js'))) {
102+
return this.getLocalMetadata()?.coreVersion === '254';
103+
}
104+
return false;
139105
}
140106

141107
// Is the static resource available on the server
@@ -193,6 +159,7 @@ export class ExperienceSite {
193159
this.metadataCache.remoteMetadata = {
194160
bundleName: staticResource.Name,
195161
bundleLastModified: staticResource.LastModifiedDate,
162+
coreVersion: '254',
196163
};
197164
return this.metadataCache.remoteMetadata;
198165
}
@@ -226,42 +193,23 @@ export class ExperienceSite {
226193
* @returns path of downloaded site zip
227194
*/
228195
public async downloadSite(): Promise<string> {
196+
let retVal;
229197
if (process.env.STATIC_MODE !== 'true') {
230-
const retVal = await this.downloadSiteV2();
231-
return retVal;
198+
// Use sites API to download the site bundle on demand
199+
retVal = await this.downloadSiteApi();
232200
} else {
233-
const remoteMetadata = await this.getRemoteMetadata();
234-
if (!remoteMetadata) {
235-
throw new SfError(`No published site found for: ${this.siteDisplayName}`);
236-
}
237-
238-
// Download the site from static resources
239-
// eslint-disable-next-line no-console
240-
console.log('[local-dev] Downloading site...'); // TODO spinner
241-
const resourcePath = this.getSiteZipPath(remoteMetadata);
242-
const staticresource = await this.org.getConnection().metadata.read('StaticResource', remoteMetadata.bundleName);
243-
if (staticresource?.content) {
244-
// Save the static resource
245-
fs.mkdirSync(this.getSiteDirectory(), { recursive: true });
246-
const buffer = Buffer.from(staticresource.content, 'base64');
247-
fs.writeFileSync(resourcePath, buffer);
248-
249-
// Save the site's metadata
250-
this.saveMetadata(remoteMetadata);
251-
} else {
252-
throw new SfError(`Error occurred downloading your site: ${this.siteDisplayName}`);
253-
}
254-
255-
return resourcePath;
201+
// This is for testing purposes only now - not an officially supported external path
202+
retVal = await this.downloadSiteStaticResources();
256203
}
204+
return retVal;
257205
}
258206

259207
/**
260208
* Generate a site bundle on demand and download it
261209
*
262210
* @returns path of downloaded site zip
263211
*/
264-
public async downloadSiteV2(): Promise<string> {
212+
public async downloadSiteApi(): Promise<string> {
265213
const remoteMetadata = await this.org
266214
.getConnection()
267215
.query<{ Id: string; Name: string; LastModifiedDate: string; MasterLabel: string }>(
@@ -273,30 +221,31 @@ export class ExperienceSite {
273221
const theSite = remoteMetadata.records[0];
274222

275223
// Download the site via API
276-
// eslint-disable-next-line no-console
277-
console.log('[local-dev] Downloading site...'); // TODO spinner
278224
const conn = this.org.getConnection();
279225
const metadata = {
280226
bundleName: theSite.Name,
281227
bundleLastModified: theSite.LastModifiedDate,
228+
coreVersion: '254',
282229
};
283230
const siteId = theSite.Id;
284231
const siteIdMinus3 = siteId.substring(0, siteId.length - 3);
285232
const accessToken = conn.accessToken;
286233
const instanceUrl = conn.instanceUrl; // Org URL
287234
if (!accessToken) {
288-
throw new SfError(`Error occurred downloading your site: ${this.siteDisplayName}`);
235+
throw new SfError(`Invalid access token, unable to download site: ${this.siteDisplayName}`);
289236
}
290237
const resourcePath = this.getSiteZipPath(metadata);
291238
try {
292-
const apiUrl = `${instanceUrl}/services/data/v63.0/sites/${siteIdMinus3}/preview`;
239+
// Limit API to published sites for now until we have a patch for the issues with unpublished sites
240+
// TODO switch api back to preview mode after issues are addressed
241+
const apiUrl = `${instanceUrl}/services/data/v63.0/sites/${siteIdMinus3}/preview?published`;
293242
const response = await axios.get(apiUrl, {
294243
headers: {
295244
Authorization: `Bearer ${accessToken}`,
296245
},
297246
responseType: 'stream',
298247
});
299-
fs.mkdirSync(this.getSiteDirectory(), { recursive: true });
248+
if (response.statusText) fs.mkdirSync(this.getSiteDirectory(), { recursive: true });
300249

301250
const fileStream = fs.createWriteStream(resourcePath);
302251
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
@@ -307,15 +256,51 @@ export class ExperienceSite {
307256
fileStream.on('error', reject);
308257
});
309258
this.saveMetadata(metadata);
310-
} catch (e) {
311-
// eslint-disable-next-line no-console
312-
console.error('failed to download site', e);
259+
} catch (error) {
260+
// Handle axios errors
261+
if (axios.isAxiosError(error)) {
262+
if (error.response) {
263+
// Server responded with non-200 status
264+
throw new SfError(
265+
`Failed to download site: Server responded with status ${error.response.status} - ${error.response.statusText}`
266+
);
267+
} else if (error.request) {
268+
// Request was made but no response received
269+
throw new SfError('Failed to download site: No response received from server');
270+
}
271+
}
272+
throw new SfError(`Failed to download site: ${this.siteDisplayName}`);
313273
}
314274

315275
// Save the site's metadata
316276
return resourcePath;
317277
}
318278

279+
// Deprecated. Only used internally now for testing. Customer sites will no longer be stored in static resources
280+
// and are only available via the API.
281+
public async downloadSiteStaticResources(): Promise<string> {
282+
const remoteMetadata = await this.getRemoteMetadata();
283+
if (!remoteMetadata) {
284+
throw new SfError(`No published site found for: ${this.siteDisplayName}`);
285+
}
286+
287+
// Download the site from static resources
288+
const resourcePath = this.getSiteZipPath(remoteMetadata);
289+
const staticresource = await this.org.getConnection().metadata.read('StaticResource', remoteMetadata.bundleName);
290+
if (staticresource?.content) {
291+
// Save the static resource
292+
fs.mkdirSync(this.getSiteDirectory(), { recursive: true });
293+
const buffer = Buffer.from(staticresource.content, 'base64');
294+
fs.writeFileSync(resourcePath, buffer);
295+
296+
// Save the site's metadata
297+
this.saveMetadata(remoteMetadata);
298+
} else {
299+
throw new SfError(`Error occurred downloading your site: ${this.siteDisplayName}`);
300+
}
301+
return resourcePath;
302+
}
303+
319304
private async getNetworkId(): Promise<string> {
320305
const conn = this.org.getConnection();
321306
// Query the Network object for the network with the given site name
@@ -332,6 +317,7 @@ export class ExperienceSite {
332317
}
333318
}
334319

320+
// TODO need to get auth tokens for the builder preview also once API issues are addressed
335321
private async getNewSidToken(networkId: string): Promise<string> {
336322
// Get the connection and access token from the org
337323
const conn = this.org.getConnection();
@@ -344,9 +330,6 @@ export class ExperienceSite {
344330

345331
// Make the GET request without following redirects
346332
if (accessToken) {
347-
// TODO should we try and refresh auth here?
348-
// await conn.refreshAuth();
349-
350333
// Call out to the switcher servlet to establish a session
351334
const switchUrl = `${instanceUrl}/servlet/networks/switch?networkId=${networkId}`;
352335
const cookies = [`sid=${accessToken}`, `oid=${orgIdMinus3}`].join('; ').trim();
@@ -409,17 +392,3 @@ export class ExperienceSite {
409392
return '';
410393
}
411394
}
412-
413-
/**
414-
* Return the site name given the name of its static resource bundle
415-
*
416-
* @param staticResourceName the static resource bundle name
417-
* @returns the name of the site
418-
*/
419-
function getSiteNameFromStaticResource(staticResourceName: string): string {
420-
const parts = staticResourceName.split('_');
421-
if (parts.length < 5) {
422-
throw new Error(`Unexpected static resource name: ${staticResourceName}`);
423-
}
424-
return parts.slice(4).join(' ');
425-
}

src/shared/promptUtils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const messages = Messages.loadMessages('@salesforce/plugin-lightning-dev', 'prom
2121

2222
export class PromptUtils {
2323
public static async promptUserToSelectSite(sites: string[]): Promise<string> {
24-
const choices = sites.map((site) => ({ value: site }));
24+
const choices = sites.sort((a, b) => a.localeCompare(b)).map((site) => ({ value: site }));
2525
const response = await select({
2626
message: messages.getMessage('site.select'),
2727
choices,

0 commit comments

Comments
 (0)