Skip to content

Commit

Permalink
feat: [sites] add get-latest cli parameter / update lwr -> v0.16.2 …
Browse files Browse the repository at this point in the history
…(includes latest local-dev fixes) (#311)
  • Loading branch information
nrkruk authored Jan 13, 2025
1 parent 5fdb5c0 commit 03fd833
Show file tree
Hide file tree
Showing 10 changed files with 339 additions and 347 deletions.
2 changes: 1 addition & 1 deletion LICENSE.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Copyright (c) 2024, Salesforce.com, Inc.
Copyright (c) 2025, Salesforce.com, Inc.
All rights reserved.

Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
Expand Down
4 changes: 2 additions & 2 deletions command-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
"alias": [],
"command": "lightning:dev:site",
"flagAliases": [],
"flagChars": ["n", "o"],
"flags": ["flags-dir", "name", "target-org"],
"flagChars": ["l", "n", "o"],
"flags": ["flags-dir", "get-latest", "name", "target-org"],
"plugin": "@salesforce/plugin-lightning-dev"
}
]
10 changes: 9 additions & 1 deletion messages/lightning.dev.site.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# summary

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

# description

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

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

# flags.get-latest.summary

Download the latest version of the specified site from your org, instead of using any local cache.

# examples

- Select a site to preview from the org "myOrg":
<%= config.bin %> <%= command.id %> --target-org myOrg
- Preview the site "Partner Central" from the org "myOrg":
<%= config.bin %> <%= command.id %> --name "Partner Central" --target-org myOrg
- Get and preview the latest version of the "Partner Central" site from the org "myOrg"
<%= config.bin %> <%= command.id %> --name "Partner Central" --target-org myOrg --get-latest
2 changes: 1 addition & 1 deletion messages/prompts.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# site.select

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

# site.confirm-update

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"@inquirer/select": "^2.4.7",
"@lwc/lwc-dev-server": "^11.1.0",
"@lwc/sfdc-lwc-compiler": "^11.1.0",
"@lwrjs/api": "0.15.6",
"@lwrjs/api": "0.16.2",
"@oclif/core": "^4.1.0",
"@salesforce/core": "^8.6.2",
"@salesforce/kit": "^3.1.6",
Expand Down
41 changes: 25 additions & 16 deletions src/commands/lightning/dev/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,18 @@ export default class LightningDevSite extends SfCommand<void> {
char: 'n',
}),
'target-org': Flags.requiredOrg(),
'get-latest': Flags.boolean({
summary: messages.getMessage('flags.get-latest.summary'),
char: 'l',
}),
};

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

try {
const org = flags['target-org'];
const getLatest = flags['get-latest'];
let siteName = flags.name;

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

if (!selectedSite.isSiteSetup()) {
this.log(`[local-dev] initializing: ${siteName}`);
// 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();
} else {
// If local-dev is already setup, check if an updated site has been published to download
const updateAvailable = await selectedSite.isUpdateAvailable();
if (updateAvailable) {
const shouldUpdate = await PromptUtils.promptUserToConfirmUpdate(siteName);
if (shouldUpdate) {
this.log(`[local-dev] updating: ${siteName}`);
siteZip = await selectedSite.downloadSite();
// delete oldSitePath recursive
const oldSitePath = selectedSite.getExtractDirectory();
if (fs.existsSync(oldSitePath)) {
fs.rmdirSync(oldSitePath, { recursive: true });
}
}

// 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 = await selectedSite.setupAuth();

Expand All @@ -94,10 +100,13 @@ export default class LightningDevSite extends SfCommand<void> {
// 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)');
}
} catch (e) {
this.spinner.stop('failed.');
this.log('Local Development setup failed', e);
}
}
Expand Down
149 changes: 59 additions & 90 deletions src/shared/experience/expSite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import axios from 'axios';
export type SiteMetadata = {
bundleName: string;
bundleLastModified: string;
coreVersion: string;
};

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

/**
* Get an experience site bundle by site name.
*
* @param conn - Salesforce connection object.
* @param siteName - The name of the experience site.
* @returns - The experience site.
*
* @param siteName
* @returns
*/
public static getLocalExpSite(siteName: string): ExperienceSite {
const siteJsonPath = path.join('.localdev', siteName.trim().replace(' ', '_'), 'site.json');
const siteJson = fs.readFileSync(siteJsonPath, 'utf8');
const site = JSON.parse(siteJson) as ExperienceSite;
return site;
}

/**
* Fetches all experience site bundles that are published to MRT.
*
* @param {Connection} conn - Salesforce connection object.
* @returns {Promise<ExperienceSite[]>} - List of experience sites.
*/
public static async getAllPublishedExpSites(org: Org): Promise<ExperienceSite[]> {
const result = await org
.getConnection()
.query<{ Id: string; Name: string; LastModifiedDate: string }>(
"SELECT Id, Name, LastModifiedDate FROM StaticResource WHERE Name LIKE 'MRT%_'"
);

// Example of creating ExperienceSite instances
const experienceSites: ExperienceSite[] = result.records.map(
(record) => new ExperienceSite(org, getSiteNameFromStaticResource(record.Name))
);

return experienceSites;
}

/**
* Fetches all current experience sites
*
Expand Down Expand Up @@ -135,7 +98,10 @@ export class ExperienceSite {

// Is the site extracted locally
public isSiteSetup(): boolean {
return fs.existsSync(path.join(this.getExtractDirectory(), 'ssr.js'));
if (fs.existsSync(path.join(this.getExtractDirectory(), 'ssr.js'))) {
return this.getLocalMetadata()?.coreVersion === '254';
}
return false;
}

// Is the static resource available on the server
Expand Down Expand Up @@ -193,6 +159,7 @@ export class ExperienceSite {
this.metadataCache.remoteMetadata = {
bundleName: staticResource.Name,
bundleLastModified: staticResource.LastModifiedDate,
coreVersion: '254',
};
return this.metadataCache.remoteMetadata;
}
Expand Down Expand Up @@ -226,42 +193,23 @@ export class ExperienceSite {
* @returns path of downloaded site zip
*/
public async downloadSite(): Promise<string> {
let retVal;
if (process.env.STATIC_MODE !== 'true') {
const retVal = await this.downloadSiteV2();
return retVal;
// Use sites API to download the site bundle on demand
retVal = await this.downloadSiteApi();
} else {
const remoteMetadata = await this.getRemoteMetadata();
if (!remoteMetadata) {
throw new SfError(`No published site found for: ${this.siteDisplayName}`);
}

// Download the site from static resources
// eslint-disable-next-line no-console
console.log('[local-dev] Downloading site...'); // TODO spinner
const resourcePath = this.getSiteZipPath(remoteMetadata);
const staticresource = await this.org.getConnection().metadata.read('StaticResource', remoteMetadata.bundleName);
if (staticresource?.content) {
// Save the static resource
fs.mkdirSync(this.getSiteDirectory(), { recursive: true });
const buffer = Buffer.from(staticresource.content, 'base64');
fs.writeFileSync(resourcePath, buffer);

// Save the site's metadata
this.saveMetadata(remoteMetadata);
} else {
throw new SfError(`Error occurred downloading your site: ${this.siteDisplayName}`);
}

return resourcePath;
// This is for testing purposes only now - not an officially supported external path
retVal = await this.downloadSiteStaticResources();
}
return retVal;
}

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

// Download the site via API
// eslint-disable-next-line no-console
console.log('[local-dev] Downloading site...'); // TODO spinner
const conn = this.org.getConnection();
const metadata = {
bundleName: theSite.Name,
bundleLastModified: theSite.LastModifiedDate,
coreVersion: '254',
};
const siteId = theSite.Id;
const siteIdMinus3 = siteId.substring(0, siteId.length - 3);
const accessToken = conn.accessToken;
const instanceUrl = conn.instanceUrl; // Org URL
if (!accessToken) {
throw new SfError(`Error occurred downloading your site: ${this.siteDisplayName}`);
throw new SfError(`Invalid access token, unable to download site: ${this.siteDisplayName}`);
}
const resourcePath = this.getSiteZipPath(metadata);
try {
const apiUrl = `${instanceUrl}/services/data/v63.0/sites/${siteIdMinus3}/preview`;
// Limit API to published sites for now until we have a patch for the issues with unpublished sites
// TODO switch api back to preview mode after issues are addressed
const apiUrl = `${instanceUrl}/services/data/v63.0/sites/${siteIdMinus3}/preview?published`;
const response = await axios.get(apiUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
responseType: 'stream',
});
fs.mkdirSync(this.getSiteDirectory(), { recursive: true });
if (response.statusText) fs.mkdirSync(this.getSiteDirectory(), { recursive: true });

const fileStream = fs.createWriteStream(resourcePath);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
Expand All @@ -307,15 +256,51 @@ export class ExperienceSite {
fileStream.on('error', reject);
});
this.saveMetadata(metadata);
} catch (e) {
// eslint-disable-next-line no-console
console.error('failed to download site', e);
} catch (error) {
// Handle axios errors
if (axios.isAxiosError(error)) {
if (error.response) {
// Server responded with non-200 status
throw new SfError(
`Failed to download site: 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 download site: No response received from server');
}
}
throw new SfError(`Failed to download site: ${this.siteDisplayName}`);
}

// Save the site's metadata
return resourcePath;
}

// Deprecated. Only used internally now for testing. Customer sites will no longer be stored in static resources
// and are only available via the API.
public async downloadSiteStaticResources(): Promise<string> {
const remoteMetadata = await this.getRemoteMetadata();
if (!remoteMetadata) {
throw new SfError(`No published site found for: ${this.siteDisplayName}`);
}

// Download the site from static resources
const resourcePath = this.getSiteZipPath(remoteMetadata);
const staticresource = await this.org.getConnection().metadata.read('StaticResource', remoteMetadata.bundleName);
if (staticresource?.content) {
// Save the static resource
fs.mkdirSync(this.getSiteDirectory(), { recursive: true });
const buffer = Buffer.from(staticresource.content, 'base64');
fs.writeFileSync(resourcePath, buffer);

// Save the site's metadata
this.saveMetadata(remoteMetadata);
} else {
throw new SfError(`Error occurred downloading your site: ${this.siteDisplayName}`);
}
return resourcePath;
}

private async getNetworkId(): Promise<string> {
const conn = this.org.getConnection();
// Query the Network object for the network with the given site name
Expand All @@ -332,6 +317,7 @@ export class ExperienceSite {
}
}

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

// Make the GET request without following redirects
if (accessToken) {
// TODO should we try and refresh auth here?
// await conn.refreshAuth();

// Call out to the switcher servlet to establish a session
const switchUrl = `${instanceUrl}/servlet/networks/switch?networkId=${networkId}`;
const cookies = [`sid=${accessToken}`, `oid=${orgIdMinus3}`].join('; ').trim();
Expand Down Expand Up @@ -409,17 +392,3 @@ export class ExperienceSite {
return '';
}
}

/**
* Return the site name given the name of its static resource bundle
*
* @param staticResourceName the static resource bundle name
* @returns the name of the site
*/
function getSiteNameFromStaticResource(staticResourceName: string): string {
const parts = staticResourceName.split('_');
if (parts.length < 5) {
throw new Error(`Unexpected static resource name: ${staticResourceName}`);
}
return parts.slice(4).join(' ');
}
2 changes: 1 addition & 1 deletion src/shared/promptUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const messages = Messages.loadMessages('@salesforce/plugin-lightning-dev', 'prom

export class PromptUtils {
public static async promptUserToSelectSite(sites: string[]): Promise<string> {
const choices = sites.map((site) => ({ value: site }));
const choices = sites.sort((a, b) => a.localeCompare(b)).map((site) => ({ value: site }));
const response = await select({
message: messages.getMessage('site.select'),
choices,
Expand Down
Loading

0 comments on commit 03fd833

Please sign in to comment.