Skip to content
Merged
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
21 changes: 21 additions & 0 deletions command-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,27 @@
],
"plugin": "@salesforce/plugin-templates"
},
{
"alias": [],
"command": "template:generate:lightning-out",
"flagAliases": ["outputdir"],
"flagChars": ["d"],
"flags": [
"app-name",
"components",
"definition-file",
"eca-callback-url",
"eca-contact-email",
"eca-name",
"flags-dir",
"host-domains",
"json",
"loglevel",
"output-dir",
"runtime"
],
"plugin": "@salesforce/plugin-templates"
},
{
"alias": ["force:lightning:app:create", "lightning:generate:app"],
"command": "template:generate:lightning:app",
Expand Down
105 changes: 105 additions & 0 deletions messages/lightningOut.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# examples

- Generate a Lightning Out 2.0 app using individual flags to specify the values:

<%= config.bin %> <%= command.id %> --app-name MyLoApp --runtime LWR_CORE --host-domains https://example.com --eca-name MyLoAppEca --eca-contact-email dev@example.com --eca-callback-url https://example.com/cb

- Generate an app using the values in a JSON definition file called lo-def.json:

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

- Generate the app into a specific directory:

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

- Generate the app using most of the values from a definition file, but the --host-domains value overrides its equivalent in the file:

<%= config.bin %> <%= command.id %> --definition-file lo-def.json --host-domains https://staging.example.com

# summary

Generate the required metadata to scaffold a Lightning Out 2.0 app.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Terminology — confirm product name. Use the approved external name "Lightning Out" ("2.0" is internal shorthand for the GA-vs-Beta distinction). Please confirm before merge. The same change applies to lines 3, 33, and 61 (lines 25 and 27 are already covered by the suggestions there).

Suggested change
Generate the required metadata to scaffold a Lightning Out 2.0 app.
Generate the required metadata to scaffold a Lightning Out app.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed this naming with our PM (Sangeeta): we're keeping Lightning Out 2.0 until the beta version is completely sunset. Otherwise devs may take "Lightning Out" to be the beta, or be unsure whether it's the beta or the latest. Once beta is deprecated we'll drop the "2.0" and use the name as-is. Leaving it unchanged for now.


# description

Lightning Out 2.0 lets you embed custom Lightning web components (LWCs) into your external, non-Salesforce apps.

This command gets you started by generating the seven metadata artifact types that a Lightning Out 2.0 app requires into your Salesforce DX project: LightningOutApp, MyDomain and Security settings, one CorsWhitelistOrigin per host domain, and the three External Client Application OAuth components (ExternalClientApplication, ExtlClntAppGlobalOauthSettings, ExtlClntAppOauthSettings). The command is generate-only; it doesn't deploy any metadata to an org.

Inputs may come from a --definition-file JSON, individual flags, or both. Flag values take precedence over the file on a per-key basis. The command validates the types of definition-file fields; the underlying generator performs the remaining structural validation, such as required fields and formats.

# flags.app-name.summary

Developer name of the new Lightning Out 2.0 app.

# flags.eca-name.summary

Developer name of the External Client Application associated with the app.

# flags.runtime.summary

Runtime the app targets. LWR_CORE serves from your Salesforce org for authenticated users; CLWR serves from an Experience Cloud site (guest access, extra site-deployment step).

# flags.host-domains.summary

HTTP or HTTPS origin of an external host page that embeds the app. Repeat the flag to specify more than one. Replaces, rather than merges with, any hostDomains in the --definition-file.

# flags.components.summary

Name of a Lightning web component exposed by the app. Repeat the flag to specify more than one. Replaces, rather than merges with, any components in the --definition-file.

# flags.eca-contact-email.summary

Contact email for the External Client Application.

# flags.eca-callback-url.summary

OAuth callback URL for the External Client Application.

# flags.definition-file.summary

Path to a JSON file describing the Lightning Out 2.0 app. Individual flags, when supplied, override the corresponding value in this file.

# error.definition-file-json

Definition file %s is not valid JSON: %s

# error.definition-file-not-object

Definition file %s must contain a single JSON object, not an array or scalar.

# error.definition-file-field-type

Definition file field "%s" must be %s.

# warning.unknown-definition-key

Ignoring unrecognized key "%s" in --definition-file.

# warning.source-api-version

Your local project's sourceApiVersion (%s) is below 68.0, the minimum API version this scaffold supports for deployment. Set sourceApiVersion to 68.0 or later in sfdx-project.json, or pass --api-version 68.0 or later when you deploy.

# success.next-step

Scaffold generated in %s. Deploy it with: sf project deploy start --source-dir %s%s

# success.app-id

After deploying, note the LightningOutApp's App ID from Setup — your host page needs it to embed the app.

# success.dont-delete

Don't delete the generated ExternalClientApplication after deploying — Lightning Out uses it for OAuth at runtime.

# success.eca-overwrite

Re-running this command overwrites the generated files for the "%s" External Client Application; back up local edits first.

# success.components-exist

Verify that every component you referenced already exists and is exposed for Lightning Out before deploying.

# success.frontdoor

For CLWR, host pages must complete a frontdoor.jsp handoff before the embedded app can authenticate.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"dependencies": {
"@salesforce/core": "^9.1.9",
"@salesforce/sf-plugins-core": "^13.0.4",
"@salesforce/templates": "^66.15.0"
"@salesforce/templates": "^66.16.0"
},
"devDependencies": {
"@oclif/plugin-command-snapshot": "^6.0.0",
Expand Down
25 changes: 25 additions & 0 deletions schemas/template-generate-lightning__out.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$ref": "#/definitions/CreateOutput",
"definitions": {
"CreateOutput": {
"type": "object",
"properties": {
"outputDir": {
"type": "string"
},
"created": {
"type": "array",
"items": {
"type": "string"
}
},
"rawOutput": {
"type": "string"
}
},
"required": ["outputDir", "created", "rawOutput"],
"additionalProperties": false
}
}
}
210 changes: 210 additions & 0 deletions src/commands/template/generate/lightning-out/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/*
* Copyright 2025, Salesforce, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import * as fs from 'node:fs';
import { Flags, loglevel, SfCommand, Ux } from '@salesforce/sf-plugins-core';
import { CreateOutput, LightningOutOptions, TemplateType } from '@salesforce/templates';
import { Messages, SfProject } from '@salesforce/core';
import { getCustomTemplates, runGenerator } from '../../../../utils/templateCommand.js';
import { outputDirFlagLightning } from '../../../../utils/flags.js';

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

/** Flat flag shape read by {@link mergeLightningOutInputs} — a structural subset of the parsed oclif flags. */
type LightningOutFlags = {
'app-name'?: string;
'eca-name'?: string;
runtime?: 'LWR_CORE' | 'CLWR';
'host-domains'?: string[];
components?: string[];
'eca-contact-email'?: string;
'eca-callback-url'?: string;
'output-dir'?: string;
};

/** Parse the --definition-file JSON, surfacing a clear error on malformed or non-object input. */
export function readDefinition(file: string): Record<string, unknown> {
let parsed: unknown;
try {
parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
} catch (e) {
throw messages.createError('error.definition-file-json', [file, (e as Error).message]);
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw messages.createError('error.definition-file-not-object', [file]);
}
return parsed as Record<string, unknown>;
}

/** True when a value is an array whose every element is a string. */
function isStringArray(v: unknown): boolean {
return Array.isArray(v) && v.every((x) => typeof x === 'string');
}

/**
* Validate the types of --definition-file fields before they reach the generator, so wrong-typed
* JSON (e.g. `"appName": 123`) yields an actionable error here rather than an internal TypeError
* from the generator (which calls `.trim()` on string fields). A field is skipped when the matching
* flag overrides it, since flags always arrive as strings and win per-key (see mergeLightningOutInputs).
*/
export function validateDefinitionShape(defn: Record<string, unknown>, flags: LightningOutFlags): void {
const requireString = (overridden: boolean, val: unknown, key: string): void => {
if (!overridden && val !== undefined && typeof val !== 'string') {
throw messages.createError('error.definition-file-field-type', [key, 'a string']);
}
};
const requireStringArray = (overridden: boolean, val: unknown, key: string): void => {
if (!overridden && val !== undefined && !isStringArray(val)) {
throw messages.createError('error.definition-file-field-type', [key, 'an array of strings']);
}
};

requireString(flags['app-name'] !== undefined, defn.appName, 'appName');
requireString(flags.runtime !== undefined, defn.runtime, 'runtime');
requireStringArray(flags['host-domains'] !== undefined, defn.hostDomains, 'hostDomains');
requireStringArray(flags.components !== undefined, defn.components, 'components');

if (defn.eca !== undefined) {
if (typeof defn.eca !== 'object' || defn.eca === null || Array.isArray(defn.eca)) {
throw messages.createError('error.definition-file-field-type', ['eca', 'an object']);
}
const eca = defn.eca as Record<string, unknown>;
requireString(flags['eca-name'] !== undefined, eca.name, 'eca.name');
requireString(flags['eca-contact-email'] !== undefined, eca.contactEmail, 'eca.contactEmail');
requireString(flags['eca-callback-url'] !== undefined, eca.callbackUrl, 'eca.callbackUrl');
}
}

/**
* Merge the --definition-file JSON with individual flags into one LightningOutOptions, with
* per-key precedence (flags win over the file) and wholesale list-replace semantics for
* hostDomains/components (never merge/concat). All structural validation (required-ness, shape,
* formats) is the generator's job — this function only resolves precedence.
*/
export function mergeLightningOutInputs(
defn: Record<string, unknown>,
flags: LightningOutFlags
): { opts: LightningOutOptions; unknownKeys: string[] } {
const known = new Set(['appName', 'runtime', 'hostDomains', 'components', 'eca']);
const unknownKeys = Object.keys(defn).filter((k) => !known.has(k));
const ecaDefn = (defn.eca ?? {}) as Record<string, unknown>;
const opts: LightningOutOptions = {
appName: flags['app-name'] ?? (defn.appName as string),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These casts allow valid JSON with incorrect value types to bypass this layer. For example, "appName": 123 or "eca": {"contactEmail": 123} reaches the generator, which calls .trim() and throws an internal TypeError such as trim is not a function, rather than an actionable definition-validation error. Could we validate the definition-file shape before constructing LightningOutOptions, or update the generator to handle unknown values safely?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added validateDefinitionShape(defn, flags) which type-checks the definition-file fields before building LightningOutOptions (new error.definition-file-field-type message: Definition file field "%s" must be %s.). {"appName": 123} or {"eca": {"contactEmail": 123}} now produces an actionable definition-validation error instead of an internal TypeError. Validation is skipped per-key when a flag overrides that field, since flags always arrive as strings and win per-key.

runtime: (flags.runtime ?? defn.runtime) as LightningOutOptions['runtime'],
hostDomains: flags['host-domains'] ?? (defn.hostDomains as string[]) ?? [],
components: flags.components ?? (defn.components as string[]),
eca: {
name: flags['eca-name'] ?? (ecaDefn.name as string),
contactEmail: flags['eca-contact-email'] ?? (ecaDefn.contactEmail as string),
callbackUrl: flags['eca-callback-url'] ?? (ecaDefn.callbackUrl as string),
},
outputdir: flags['output-dir'],
};
return { opts, unknownKeys };
}

/**
* Resolve the local DX project's `sourceApiVersion`, used only for a CLI-side advisory warning
* (the generator itself has no project context). Returns undefined when there's no DX project —
* generating outside a project must not error.
*/
async function getSourceApiVersion(): Promise<string | undefined> {
try {
const project = await SfProject.resolve();
const projectJson = await project.resolveProjectConfig();
return projectJson.sourceApiVersion as string | undefined;
} catch (e) {
return undefined;
}
}

/** True when a project sourceApiVersion is present and below the v68.0 deploy floor. Pure; testable. */
export function isBelowApiFloor(projApi: string | undefined): boolean {
return !!projApi && Number(projApi) < 68;
}

/**
* Quote a path for safe copy-paste into a POSIX shell when it contains whitespace or quote chars,
* so the suggested deploy command survives output dirs such as "/tmp/Lightning Out".
*/
export function shellQuoteArg(p: string): string {
return /[\s"'\\]/.test(p) ? `'${p.replace(/'/g, "'\\''")}'` : p;
}

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 = {
'app-name': Flags.string({ summary: messages.getMessage('flags.app-name.summary'), required: false }),
'eca-name': Flags.string({ summary: messages.getMessage('flags.eca-name.summary'), required: false }),
runtime: Flags.option({
options: ['LWR_CORE', 'CLWR'] as const,
summary: messages.getMessage('flags.runtime.summary'),
})(),
'host-domains': Flags.string({ summary: messages.getMessage('flags.host-domains.summary'), multiple: true }),
components: Flags.string({ summary: messages.getMessage('flags.components.summary'), multiple: true }),
'eca-contact-email': Flags.string({ summary: messages.getMessage('flags.eca-contact-email.summary') }),
'eca-callback-url': Flags.string({ summary: messages.getMessage('flags.eca-callback-url.summary') }),
'definition-file': Flags.file({ exists: true, summary: messages.getMessage('flags.definition-file.summary') }),
'output-dir': outputDirFlagLightning,
loglevel,
};

public async run(): Promise<CreateOutput> {
const { flags } = await this.parse(LightningOut);
const defn = flags['definition-file'] ? readDefinition(flags['definition-file']) : {};
validateDefinitionShape(defn, flags);
const { opts, unknownKeys } = mergeLightningOutInputs(defn, flags);

unknownKeys.forEach((k) => this.warn(messages.getMessage('warning.unknown-definition-key', [k])));

const result = await runGenerator({
templateType: TemplateType.LightningOut,
opts,
ux: new Ux({ jsonEnabled: this.jsonEnabled() }),
templates: getCustomTemplates(this.configAggregator),
});

// CLI-side sourceApiVersion floor check (the generator has no project context).
const projApi = await getSourceApiVersion();
const belowFloor = isBelowApiFloor(projApi);
if (belowFloor) {
this.warn(messages.getMessage('warning.source-api-version', [String(projApi)]));
}

// Success guidance (suppressed automatically under --json).
// Only pin --api-version to the 68.0 deploy floor when the project is below it; otherwise omit
// the optional flag so the user's own (>= floor) project default applies rather than being downgraded.
const outputDir = opts.outputdir ?? '.';
const apiVersionSuffix = belowFloor ? ' --api-version 68.0' : '';
this.log(messages.getMessage('success.next-step', [outputDir, shellQuoteArg(outputDir), apiVersionSuffix]));
this.info(messages.getMessage('success.app-id'));
this.info(messages.getMessage('success.dont-delete'));
this.info(messages.getMessage('success.eca-overwrite', [opts.eca.name ?? '']));
this.info(messages.getMessage('success.components-exist'));
// frontdoor.jsp handoff is a CLWR-only concern; don't show it for LWR_CORE runs.
if (opts.runtime === 'CLWR') {
this.info(messages.getMessage('success.frontdoor'));
}

return result; // --json returns the full CreateOutput (created[])
}
}
Loading
Loading