Skip to content

Commit

Permalink
feat: update sites locally after publish (#136)
Browse files Browse the repository at this point in the history
  • Loading branch information
nrkruk authored Aug 15, 2024
1 parent b12ac30 commit d61cffe
Show file tree
Hide file tree
Showing 6 changed files with 262 additions and 145 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@salesforce/lwc-dev-mobile-core": "4.0.0-alpha.7",
"@salesforce/sf-plugins-core": "^11.2.4",
"@inquirer/select": "^2.4.7",
"@inquirer/prompts": "^5.3.8",
"chalk": "^5.3.0",
"lwc": "7.1.3",
"lwr": "0.14.0",
Expand Down
53 changes: 23 additions & 30 deletions src/commands/lightning/dev/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,16 @@
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
// import fs from 'node:fs';
// import path from 'node:path';
import fs from 'node:fs';
import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
import { Messages } from '@salesforce/core';
import { expDev } from '@lwrjs/api';
import { PromptUtils } from '../../../shared/prompt.js';
// import { OrgUtils } from '../../../shared/orgUtils.js';
import { ExperienceSite } from '../../../shared/experience/expSite.js';

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

export type LightningDevSiteResult = {
path: string;
};

export default class LightningDevSite extends SfCommand<void> {
public static readonly summary = messages.getMessage('summary');
public static readonly description = messages.getMessage('description');
Expand All @@ -40,50 +34,49 @@ export default class LightningDevSite extends SfCommand<void> {
public async run(): Promise<void> {
const { flags } = await this.parse(LightningDevSite);

// TODO short circuit all this if user specifies a site name and it exists locally

try {
// 1. Connect to Org
const org = flags['target-org'];
let siteName = flags.name;

// 2. If we don't have a site to use, prompt the user for one
// If user doesn't specify a site, prompt the user for one
if (!siteName) {
this.log('No site was specified');
// Allow user to pick a site
const siteList = await ExperienceSite.getAllExpSites(org.getConnection());
siteName = await PromptUtils.promptUserToSelectSite(siteList);
const allSites = await ExperienceSite.getAllExpSites(org);
siteName = await PromptUtils.promptUserToSelectSite(allSites);
}

// 3. Setup local dev directory structure: '.localdev/${site}'
this.log(`Setting up Local Development for: ${siteName}`);
const selectedSite = new ExperienceSite(org, siteName);
let siteZip;
let siteZip: string | undefined;

if (!selectedSite.isSiteSetup()) {
// TODO Verify the bundle has been published and download
this.log('Downloading Site...');
this.log(`[local-dev] initializing: ${siteName}`);
siteZip = await selectedSite.downloadSite();
} else {
// If we do have the site setup already, don't do anything / TODO prompt the user if they want to get latest?
// Check if the site has been published
// const result = await connection.query<{ Id: string; Name: string; LastModifiedDate: string }>(
// "SELECT Id, Name, LastModifiedDate FROM StaticResource WHERE Name LIKE 'MRT%" + siteName + "'"
// );
// this.log('Setup already complete!');
// 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 });
}
}
}
}

// 6. Start the dev server
this.log('Starting local development server...');
// Start the dev server
await expDev({
open: false,
open: true,
port: 3000,
logLevel: 'error',
mode: 'dev',
siteZip,
siteDir: selectedSite.getSiteDirectory(),
});
} catch (e) {
// this.error(e);
this.log('Local Development setup failed', e);
}
}
Expand Down
180 changes: 108 additions & 72 deletions src/shared/experience/expSite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,20 @@
*/
import fs from 'node:fs';
import path from 'node:path';
import { Connection, Org, SfError } from '@salesforce/core';
import { Org, SfError } from '@salesforce/core';

export type SiteMetadata = {
bundleName: string;
bundleLastModified: string;
};

export type SiteMetadataCache = {
[key: string]: SiteMetadata;
};

/**
* Experience Site class.
* https://developer.salesforce.com/docs/platform/lwc/guide/get-started-test-components.html#enable-local-dev
*
* @param {string} siteName - The name of the experience site.
* @param {string} status - The status of the experience site.
Expand All @@ -19,19 +29,13 @@ import { Connection, Org, SfError } from '@salesforce/core';
export class ExperienceSite {
public siteDisplayName: string;
public siteName: string;
public status: string;

private org: Org;
private bundleName: string;
private bundleLastModified: string;
private metadataCache: SiteMetadataCache = {};

public constructor(org: Org, siteName: string, status?: string, bundleName?: string, bundleLastModified?: string) {
public constructor(org: Org, siteName: string) {
this.org = org;
this.siteDisplayName = siteName.trim();
this.siteName = this.siteDisplayName.replace(' ', '_');
this.status = status ?? '';
this.bundleName = bundleName ?? '';
this.bundleLastModified = bundleLastModified ?? '';
}

/**
Expand All @@ -45,7 +49,6 @@ export class ExperienceSite {
* @returns
*/
public static getLocalExpSite(siteName: string): ExperienceSite {
// TODO cleanup
const siteJsonPath = path.join('.localdev', siteName.trim().replace(' ', '_'), 'site.json');
const siteJson = fs.readFileSync(siteJsonPath, 'utf8');
const site = JSON.parse(siteJson) as ExperienceSite;
Expand All @@ -67,14 +70,7 @@ export class ExperienceSite {

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

return experienceSites;
Expand All @@ -86,8 +82,8 @@ export class ExperienceSite {
* @param {Connection} conn - Salesforce connection object.
* @returns {Promise<string[]>} - List of experience sites.
*/
public static async getAllExpSites(conn: Connection): Promise<string[]> {
const result = await conn.query<{
public static async getAllExpSites(org: Org): Promise<string[]> {
const result = await org.getConnection().query<{
Id: string;
Name: string;
LastModifiedDate: string;
Expand All @@ -98,42 +94,84 @@ export class ExperienceSite {
return experienceSites;
}

public isSiteSetup(): boolean {
return fs.existsSync(path.join(this.getExtractDirectory(), 'ssr.js'));
public async isUpdateAvailable(): Promise<boolean> {
const localMetadata = this.getLocalMetadata();
if (!localMetadata) {
return true; // If no local metadata, assume update is available
}

const remoteMetadata = await this.getRemoteMetadata();
if (!remoteMetadata) {
return false; // If no org bundle found, no update available
}

return new Date(remoteMetadata.bundleLastModified) > new Date(localMetadata.bundleLastModified);
}

public isSitePublished(): boolean {
// TODO
// Is the site extracted locally
public isSiteSetup(): boolean {
return fs.existsSync(path.join(this.getExtractDirectory(), 'ssr.js'));
}

public async getBundleName(): Promise<string> {
if (!this.bundleName) {
await this.initBundle();
// Is the static resource available on the server
public async isSitePublished(): Promise<boolean> {
const remoteMetadata = await this.getRemoteMetadata();
if (!remoteMetadata) {
return false;
}

return this.bundleName;
return true;
}

public async getBundleLastModified(): Promise<string> {
if (!this.bundleLastModified) {
await this.initBundle();
// Is there a local gz file of the site
public isSiteDownloaded(): boolean {
const metadata = this.getLocalMetadata();
if (!metadata) {
return false;
}
return this.bundleLastModified;
return fs.existsSync(this.getSiteZipPath(metadata));
}

/**
* Save the site metadata to the file system.
*/
public save(): void {
public saveMetadata(metadata: SiteMetadata): void {
const siteJsonPath = path.join(this.getSiteDirectory(), 'site.json');
const siteJson = JSON.stringify(this, null, 4);

// write out the site metadata
fs.mkdirSync(this.getSiteDirectory(), { recursive: true });
const siteJson = JSON.stringify(metadata, null, 2);
fs.writeFileSync(siteJsonPath, siteJson);
}

public getLocalMetadata(): SiteMetadata | undefined {
if (this.metadataCache.localMetadata) return this.metadataCache.localMetadata;
const siteJsonPath = path.join(this.getSiteDirectory(), 'site.json');
let siteJson;
if (fs.existsSync(siteJsonPath)) {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
siteJson = JSON.parse(fs.readFileSync(siteJsonPath, 'utf-8')) as SiteMetadata;
this.metadataCache.localMetadata = siteJson;
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error reading site.json file', error);
}
}
return siteJson;
}

public async getRemoteMetadata(): Promise<SiteMetadata | undefined> {
if (this.metadataCache.remoteMetadata) return this.metadataCache.remoteMetadata;
const result = await this.org
.getConnection()
.query<{ Name: string; LastModifiedDate: string }>(
`SELECT Name, LastModifiedDate FROM StaticResource WHERE Name LIKE 'MRT_experience_%_${this.siteName}'`
);
if (result.records.length === 0) {
return undefined;
}
const staticResource = result.records[0];
this.metadataCache.remoteMetadata = {
bundleName: staticResource.Name,
bundleLastModified: staticResource.LastModifiedDate,
};
return this.metadataCache.remoteMetadata;
}

/**
* Get the local site directory path
*
Expand All @@ -147,47 +185,45 @@ export class ExperienceSite {
return path.join('.localdev', this.siteName, 'app');
}

public getSiteZipPath(metadata: SiteMetadata): string {
const lastModifiedDate = new Date(metadata.bundleLastModified);
const timestamp = `${
lastModifiedDate.getMonth() + 1
}-${lastModifiedDate.getDate()}_${lastModifiedDate.getHours()}-${lastModifiedDate.getMinutes()}`;
const fileName = `${metadata.bundleName}_${timestamp}.gz`;
const resourcePath = path.join(this.getSiteDirectory(), fileName);
return resourcePath;
}

/**
* Download and return the site resource bundle
*
* @returns path of downloaded site zip
*/
public async downloadSite(): Promise<string> {
// 3a. Locate the site bundle
const bundleName = await this.getBundleName();

// 3b. Download the site from static resources
const resourcePath = path.join(this.getSiteDirectory(), `${bundleName}.gz`);

// TODO configure redownloading
if (!fs.existsSync(resourcePath)) {
const staticresource = await this.org.getConnection().metadata.read('StaticResource', bundleName);
if (staticresource?.content) {
fs.mkdirSync(this.getSiteDirectory(), { recursive: true });
// Save the static resource
const buffer = Buffer.from(staticresource.content, 'base64');
// this.log(`Writing file to path: ${resourcePath}`);
fs.writeFileSync(resourcePath, buffer);
} else {
throw new SfError(`Error occured downloading your site: ${this.siteDisplayName}`);
}
const remoteMetadata = await this.getRemoteMetadata();
if (!remoteMetadata) {
throw new SfError(`No published site found for: ${this.siteDisplayName}`);
}
return resourcePath;
}

private async initBundle(): Promise<void> {
const result = await this.org
.getConnection()
.query<{ Id: string; Name: string; LastModifiedDate: string }>(
"SELECT Id, Name, LastModifiedDate FROM StaticResource WHERE Name LIKE 'MRT_experience_%_" + this.siteName + "'"
);
if (result.records.length === 0) {
throw new Error(`No experience site found for siteName: ${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}`);
}

const staticResource = result.records[0];
this.bundleName = staticResource.Name;
this.bundleLastModified = staticResource.LastModifiedDate;
return resourcePath;
}
}

Expand Down
Loading

0 comments on commit d61cffe

Please sign in to comment.