Skip to content
Draft
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
75 changes: 75 additions & 0 deletions messages/lightningOut.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# examples

- Generate a Lightning Out 2.0 scaffold from a definition file into the current directory:

<%= config.bin %> <%= command.id %> --definition-file lo-def.json

- Generate into a specific directory:

<%= config.bin %> <%= command.id %> --definition-file lo-def.json --output-dir force-app/main/default

- Overwrite files from a previous run:

<%= config.bin %> <%= command.id %> --definition-file lo-def.json --force

- Preserve the org's existing Trusted Domains for Inline Frames (retrieve-merge) instead of replacing them:

<%= config.bin %> <%= command.id %> --definition-file lo-def.json --merge-iframe --target-org myOrg

# summary

Generate the metadata scaffold for a Lightning Out 2.0 application.

# description

Generates the seven metadata artifact types a Lightning Out 2.0 app requires: LightningOutApp, IframeWhiteListUrlSettings, MyDomain and Security settings, one CorsWhitelistOrigin per host domain, and the External Client Application OAuth trio (ExternalClientApplication, ExtlClntAppGlobalOauthSettings, ExtlClntAppOauthSettings). The command is generate-only; it does not deploy.

IMPORTANT: Deploying the generated IframeWhiteListUrlSettings REPLACES your org's entire "Trusted Domains for Inline Frames" list (Setup > Security > Session Settings), across every IFrame Type. By default the generated file contains only this app's host domains. To preserve the org's existing entries, pass --merge-iframe with --target-org; the command then retrieves the current list and merges this app's domains into it.

# flags.definition-file.summary

Path to a JSON file describing the Lightning Out 2.0 app.

# flags.definition-file.description

The JSON must contain: name (a valid Metadata API name), runtime (LWR_CORE or CLWR), components (a non-empty array of Lightning web component names), hostDomains (a non-empty array of https origins), and eca (at least a contactEmail; optionally distributionState, callbackUrl, and oauthScopes).

# flags.force.summary

Overwrite existing files instead of erroring.

# flags.force.description

By default, generation fails if any target file already exists, so a re-run never silently overwrites your edits — notably the REPLACE-type IframeWhiteListUrlSettings file. Pass --force to overwrite.

# flags.merge-iframe.summary

Preserve the org's existing Trusted Domains for Inline Frames by merging into them.

# flags.merge-iframe.description

Retrieves the target org's current IframeWhiteListUrlSettings and re-emits every existing entry (across all IFrame Types) into the generated file, then adds this app's host domains. Requires --target-org. Without this flag, the generated file contains only this app's host domains and deploying it REPLACES the org's entire list.

# flags.target-org.summary

Org whose Trusted Domains for Inline Frames list is retrieved when --merge-iframe is set.

# warning.iframe-replace

The generated IframeWhiteListUrlSettings lists only this app's host domains. Deploying it REPLACES your org's entire "Trusted Domains for Inline Frames" list across all IFrame Types. To preserve existing entries, re-run with --merge-iframe --target-org <org>.

# info.merged-iframe-count

Retrieved %s existing Trusted Domains for Inline Frames entries from the org; the generated file merges this app's host domains into them.

# error.merge-iframe-requires-org

--merge-iframe requires a target org. Pass --target-org <username-or-alias>.

# error.definition-file-read

Unable to read definition file %s: %s

# error.definition-file-json

Definition file %s is not valid JSON: %s
114 changes: 114 additions & 0 deletions src/commands/template/generate/lightning-out/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2026, salesforce.com, inc.
* All rights reserved.
* 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 { readFile } from 'node:fs/promises';
import { Flags, loglevel, orgApiVersionFlagWithDeprecations, SfCommand, Ux } from '@salesforce/sf-plugins-core';
import { CreateOutput, IframeWhiteListEntry, LightningOutOptions, TemplateType } from '@salesforce/templates';
import { Messages, SfError } from '@salesforce/core';
import { getCustomTemplates, runGenerator } from '../../../../utils/templateCommand.js';
import { outputDirFlagLightning } from '../../../../utils/flags.js';
import { retrieveIframeEntries } from '../../../../utils/lightningOutIframe.js';

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

/** Shape of the --definition-file JSON (spec §3.2). */
type LightningOutDefinition = {
name?: string;
runtime?: LightningOutOptions['runtime'];
components?: string[];
hostDomains?: string[];
eca?: LightningOutOptions['eca'];
};

/** Parse the definition file as JSON, surfacing a clear error on malformed input. */
async function readDefinition(file: string): Promise<LightningOutDefinition> {
let raw: string;
try {
raw = await readFile(file, 'utf8');
} catch (e) {
throw new SfError(messages.getMessage('error.definition-file-read', [file, (e as Error).message]));
}
try {
return JSON.parse(raw) as LightningOutDefinition;
} catch (e) {
throw new SfError(messages.getMessage('error.definition-file-json', [file, (e as Error).message]));
}
}

export default class LightningOut extends SfCommand<CreateOutput> {
public static readonly summary = messages.getMessage('summary');
public static readonly description = messages.getMessage('description');
public static readonly examples = messages.getMessages('examples');
public static readonly state = 'beta';
public static readonly hidden = true;

public static readonly flags = {
'definition-file': Flags.file({
char: 'f',
summary: messages.getMessage('flags.definition-file.summary'),
description: messages.getMessage('flags.definition-file.description'),
required: true,
exists: true,
}),
'output-dir': outputDirFlagLightning,
force: Flags.boolean({
summary: messages.getMessage('flags.force.summary'),
description: messages.getMessage('flags.force.description'),
default: false,
}),
'merge-iframe': Flags.boolean({
summary: messages.getMessage('flags.merge-iframe.summary'),
description: messages.getMessage('flags.merge-iframe.description'),
default: false,
}),
'target-org': Flags.optionalOrg({
summary: messages.getMessage('flags.target-org.summary'),
}),
'api-version': orgApiVersionFlagWithDeprecations,
loglevel,
};

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

const def = await readDefinition(flags['definition-file']);

// Option B (retrieve-merge): read the org's current Trusted Domains for
// Inline Frames so the generator preserves them instead of wiping the list.
let existingIframeEntries: IframeWhiteListEntry[] | undefined;
if (flags['merge-iframe']) {
const org = flags['target-org'];
if (!org) {
throw new SfError(messages.getMessage('error.merge-iframe-requires-org'));
}
existingIframeEntries = await retrieveIframeEntries(org.getConnection(flags['api-version']));
this.info(messages.getMessage('info.merged-iframe-count', [existingIframeEntries.length]));
} else {
this.warn(messages.getMessage('warning.iframe-replace'));
}

const flagsAsOptions: LightningOutOptions = {
name: def.name as string,
runtime: def.runtime as LightningOutOptions['runtime'],
components: def.components as string[],
hostDomains: def.hostDomains as string[],
eca: def.eca as LightningOutOptions['eca'],
outputdir: flags['output-dir'],
apiversion: flags['api-version'],
force: flags.force,
existingIframeEntries,
};

return runGenerator({
templateType: TemplateType.LightningOut,
opts: flagsAsOptions,
ux: new Ux({ jsonEnabled: this.jsonEnabled() }),
templates: getCustomTemplates(this.configAggregator),
});
}
}
59 changes: 59 additions & 0 deletions src/utils/lightningOutIframe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2026, salesforce.com, inc.
* All rights reserved.
* 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 { Connection } from '@salesforce/core';
import { IframeWhiteListEntry } from '@salesforce/templates';

/** Metadata API full name of the singleton IframeWhiteListUrlSettings record. */
const IFRAME_SETTINGS_TYPE = 'IframeWhiteListUrlSettings';

/** Shape of one <iframeWhiteListUrls> element as returned by the Metadata API read(). */
type RawIframeUrl = {
url?: string;
context?: string;
};

/** Shape of the IframeWhiteListUrlSettings metadata record. */
type RawIframeSettings = {
fullName?: string;
iframeWhiteListUrls?: RawIframeUrl | RawIframeUrl[];
};

/**
* Normalize the Metadata API's read() result — which returns a single object
* for a scalar field and an array for a repeated field — into a plain array.
*/
function toArray<T>(value: T | T[] | undefined): T[] {
if (value === undefined || value === null) {
return [];
}
return Array.isArray(value) ? value : [value];
}

/**
* Option B (retrieve-merge). Read the org's CURRENT "Trusted Domains for Inline
* Frames" list (IframeWhiteListUrlSettings) and return every entry as a
* {url, context} pair — across ALL IFrame Types, not just LightningOut — so the
* generator can re-emit them verbatim and avoid wiping the org's list when the
* REPLACE-type settings artifact is deployed.
*/
export async function retrieveIframeEntries(conn: Connection): Promise<IframeWhiteListEntry[]> {
// `metadata.read` types the metadata type as a closed union that predates
// IframeWhiteListUrlSettings, so cast through the generic string overload.
const read = await conn.metadata.read(
IFRAME_SETTINGS_TYPE as Parameters<typeof conn.metadata.read>[0],
IFRAME_SETTINGS_TYPE
);
const record = (Array.isArray(read) ? read[0] : read) as RawIframeSettings | undefined;

return toArray(record?.iframeWhiteListUrls)
.filter((u): u is RawIframeUrl & { url: string } => typeof u?.url === 'string' && u.url.length > 0)
.map((u) => ({
url: u.url,
context: typeof u.context === 'string' && u.context.length > 0 ? u.context : 'LightningOut',
}));
}
Loading