@salesforce-ui-embedding/internal-lightning-ui-embedding 2.2.7-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,965 @@
1
+ /*
2
+ * Copyright 2026 Salesforce, Inc. All rights reserved.
3
+ * For full license text, see the LICENSE.txt file in this package.
4
+ */
5
+
6
+ import { api, LightningElement } from "lwc";
7
+
8
+ import { buildBootstrapEnvelope, validateHeartbeat, validateShutdown } from "./utils/bootstrap";
9
+ import { DEFAULT_HEARTBEAT_TIMEOUT_MS, ERROR_CODES, ERROR_PHASES, STATES } from "./utils/constants";
10
+ import { HostBridge } from "./utils/host-bridge";
11
+ import { HOST_INFO } from "./utils/host-info";
12
+ import { createInstrumentation } from "./utils/instrumentation";
13
+ import { createLogger } from "./utils/log";
14
+ import { computeSandboxTokens } from "./utils/sandbox";
15
+ import {
16
+ EVENTS_DISPATCH_METHOD,
17
+ EVENTS_SUBSCRIBE_METHOD,
18
+ EVENTS_UNSUBSCRIBE_METHOD,
19
+ HEARTBEAT_TYPE,
20
+ HostLwcEvent,
21
+ RESIZE_METHOD,
22
+ SHUTDOWN_TYPE,
23
+ UI_STATE_CHANGED_METHOD,
24
+ UI_STATE_SUBSCRIBE_METHOD,
25
+ UI_STATE_UNSUBSCRIBE_METHOD,
26
+ } from "./utils/sf-embedding-contract";
27
+ import { composeUiState, MIRRORED_ATTRIBUTES } from "./utils/ui-state";
28
+ import { appendHostMetaData, parseOrigin } from "./utils/url";
29
+
30
+ /** Stable reference for null/undefined `props` so the setter's `===` guard can short-circuit. */
31
+ const EMPTY_PROPS = Object.freeze({});
32
+
33
+ /** Reserved event names that must never be forwarded (component's own outbound events). */
34
+ const RESERVED_EVENT_NAMES = new Set([HostLwcEvent.READY, HostLwcEvent.ERROR]);
35
+
36
+ /**
37
+ * Normalize event.detail for MessageChannel: JSON round-trip an object to strip LWC/LWS membrane
38
+ * proxies that fail structured-clone. Strings and other primitives pass through untouched (no auto-parse).
39
+ *
40
+ * @remarks Intentional JSON-only contract — structuredClone is NOT usable here (it throws DataCloneError on the
41
+ * membrane proxy identically to postMessage; the round-trip reads through the proxy via property gets). The contract
42
+ * is lossy for out-of-band types: Date→ISO string, Map/Set→{}, undefined/functions/symbols dropped, NaN/Infinity→null,
43
+ * BigInt/circular refs throw (caught + logged as EVENT_FORWARD_FAILED, forward skipped, local dispatch still fires).
44
+ * Dispatch only primitives + plain objects in CustomEvent detail.
45
+ */
46
+ function toCloneableDetail(detail) {
47
+ if (detail !== null && typeof detail === "object") {
48
+ return JSON.parse(JSON.stringify(detail));
49
+ }
50
+ return detail;
51
+ }
52
+
53
+ export default class Embedding extends LightningElement {
54
+ _iframe = null;
55
+ _container = null;
56
+ _frameShadow = null;
57
+ _iframeCreated = false;
58
+
59
+ _currentState = STATES.PRE_HEARTBEAT;
60
+ _instanceId = crypto.randomUUID();
61
+ _debugEnabled = false;
62
+ _log = createLogger("[lightning-ui-embedding]", () => this._debugEnabled);
63
+
64
+ _channel = null;
65
+ _hostPort = null;
66
+ _portTransferred = false;
67
+ _heartbeatHandler = null;
68
+
69
+ // True once the iframe element has fired at least one `load` event on the current channel.
70
+ // Reset in `_resetChannel`. Paired with `_portTransferred` to discriminate a real full-doc-nav
71
+ // reload from the initial-bootstrap load that fires AFTER the embedded document's heartbeat.
72
+ //
73
+ // Why this matters: when the embedded app uses the Platform SDK, the SDK defers its heartbeat
74
+ // until DOMContentLoaded, so the parent's `load` task is queued before the SDK's `message`
75
+ // task and the natural order holds. But the protocol is also documented as plain
76
+ // `postMessage`-friendly so integrators (e.g. DVAG, who asked us to support this) can embed
77
+ // without the SDK — an inline `<script>` in the document head can call
78
+ // `parent.postMessage("sf-embedding/ready", …)` during HTML parse, long before the iframe's
79
+ // `load` event fires at the parent. The parent's event loop is then free to process the
80
+ // queued `message` task before the queued `load` task, so the very first `load` arrives with
81
+ // the port already transferred.
82
+ _initialLoadConsumed = false;
83
+
84
+ // Infinity disables the deadline; AbortController allows active cancellation.
85
+ _heartbeatTimeoutMs = DEFAULT_HEARTBEAT_TIMEOUT_MS;
86
+ _heartbeatController = null;
87
+
88
+ _src = null;
89
+ _iframeSrcOrigin = null;
90
+ _sandbox = null;
91
+ _removeSandboxTokens = null;
92
+ _title = "Embedded widget";
93
+
94
+ _bridge = null;
95
+ _negotiatedProtocolVersion = null;
96
+
97
+ // UiState provider state. The active wire subscription itself lives on the
98
+ // HostBridge (it owns the inbound subscribe/unsubscribe requests); the
99
+ // component owns the snapshot content and change detection below.
100
+ _props = EMPTY_PROPS;
101
+ _mutationObserver = null;
102
+ _uiStateFlushScheduled = false;
103
+
104
+ /** Re-entrancy guard: tracks the specific event objects we're re-dispatching from the embedding,
105
+ * so the override below skips only those (not unrelated events fired by listeners during the same task). */
106
+ _inboundDispatches = new WeakSet();
107
+ _forwardTarget = null;
108
+ _originalDispatchEvent = null;
109
+
110
+ // Instrumentation
111
+ _instrumentation = null;
112
+ _loadActivity = null;
113
+ _loadActivityStopped = false;
114
+ _activityStartedAt = 0;
115
+
116
+ @api
117
+ get src() {
118
+ return this._src;
119
+ }
120
+ set src(value) {
121
+ const val = value || null;
122
+ if (this._src === val) return;
123
+ if (!this._canMutateSessionBinding("src")) return;
124
+ this._src = val;
125
+ this._iframeSrcOrigin = val ? parseOrigin(val) : null;
126
+ this._updateIframeSrc();
127
+ }
128
+
129
+ @api
130
+ get sandbox() {
131
+ return this._sandbox;
132
+ }
133
+ set sandbox(value) {
134
+ const val = value || null;
135
+ if (this._sandbox === val) return;
136
+ if (!this._canMutateSessionBinding("sandbox")) return;
137
+ this._sandbox = val;
138
+ this._applySandbox();
139
+ }
140
+
141
+ @api
142
+ get removeSandboxTokens() {
143
+ return this._removeSandboxTokens;
144
+ }
145
+ set removeSandboxTokens(value) {
146
+ const val = value || null;
147
+ if (this._removeSandboxTokens === val) return;
148
+ if (!this._canMutateSessionBinding("removeSandboxTokens")) return;
149
+ this._removeSandboxTokens = val;
150
+ this._applySandbox();
151
+ }
152
+
153
+ @api
154
+ get props() {
155
+ return this._props;
156
+ }
157
+ set props(value) {
158
+ // Reference equality: authors must assign a new object to trigger ui-state-changed.
159
+ // null/undefined coerce to the shared EMPTY_PROPS sentinel so repeated unsets are a no-op.
160
+ const next = value ?? EMPTY_PROPS;
161
+ if (this._props === next) return;
162
+ this._props = next;
163
+ this._scheduleUiStateFlush();
164
+ }
165
+
166
+ @api
167
+ get title() {
168
+ return this._title;
169
+ }
170
+ set title(value) {
171
+ this._title = value || "embedding";
172
+ this._reflectHostTitle();
173
+ this._updateTitle();
174
+ }
175
+
176
+ @api
177
+ get debug() {
178
+ return this._debugEnabled;
179
+ }
180
+ set debug(value) {
181
+ this._debugEnabled = !!value;
182
+ }
183
+
184
+ // Positive number arms the deadline; Infinity disables it; anything else uses the default.
185
+ // Advisory only — fires sf-embedding.component.error / HEARTBEAT_TIMEOUT, no teardown.
186
+ @api
187
+ get heartbeatTimeoutMs() {
188
+ return this._heartbeatTimeoutMs;
189
+ }
190
+ set heartbeatTimeoutMs(value) {
191
+ if (typeof value !== "number" || Number.isNaN(value) || value <= 0) {
192
+ this._heartbeatTimeoutMs = DEFAULT_HEARTBEAT_TIMEOUT_MS;
193
+ } else {
194
+ this._heartbeatTimeoutMs = value === Infinity ? Infinity : Math.floor(value);
195
+ }
196
+ if (this._heartbeatController) {
197
+ this._armHeartbeatDeadline();
198
+ }
199
+ }
200
+
201
+ @api
202
+ get bootstrapPhase() {
203
+ return this._currentState;
204
+ }
205
+
206
+ @api
207
+ get resolvedSandbox() {
208
+ return computeSandboxTokens(this._sandbox, this._removeSandboxTokens);
209
+ }
210
+
211
+ get currentState() {
212
+ return this._currentState;
213
+ }
214
+
215
+ connectedCallback() {
216
+ this._installHeartbeatListener();
217
+ this._installMutationObserver();
218
+ this._installHostDispatchOverride();
219
+ this._instrumentation = createInstrumentation("LightningUiEmbedding", this._src, window.location.origin);
220
+ this._startLoadActivity();
221
+ this._log("connectedCallback");
222
+ }
223
+
224
+ renderedCallback() {
225
+ if (this._iframeCreated) return;
226
+ this._iframeCreated = true;
227
+
228
+ this._container = this.template.querySelector(".container");
229
+ if (!this._container) return;
230
+
231
+ const configError = this._validateConfiguration();
232
+ if (configError) {
233
+ this._instrumentation?.incrementCounter("sfembedding.config.validation_failed", 1, true, {
234
+ reason: configError.code,
235
+ });
236
+ this._error(ERROR_PHASES.CONFIGURATION, configError.code, configError.message, STATES.CLOSED);
237
+ return;
238
+ }
239
+
240
+ this._frameShadow = this._container.attachShadow({ mode: "closed" });
241
+ const innerStyle = document.createElement("style");
242
+ innerStyle.textContent = `
243
+ .frame {
244
+ visibility: var(--frame-visibility, hidden);
245
+ display: block;
246
+ width: 100%;
247
+ height: 100%;
248
+ border: none;
249
+ }
250
+ `;
251
+ this._frameShadow.appendChild(innerStyle);
252
+
253
+ const frame = document.createElement("iframe");
254
+ frame.className = "frame";
255
+ frame.title = this._title;
256
+ frame.addEventListener("load", () => this._handleIframeLoad());
257
+
258
+ this._frameShadow.appendChild(frame);
259
+ this._iframe = frame;
260
+
261
+ this._applySandbox();
262
+ this._updateIframeSrc();
263
+
264
+ this._log("renderedCallback: iframe ready");
265
+ }
266
+
267
+ // src/sandbox/removeSandboxTokens are session-binding — read at mount, MUST NOT
268
+ // be mutated mid-instance. Post-mount changes are an unmount/remount boundary, so we reject
269
+ // them with a configuration error rather than silently re-rendering.
270
+ _canMutateSessionBinding(prop) {
271
+ if (!this._iframeCreated) return true;
272
+ this._instrumentation?.incrementCounter("sfembedding.session_binding.mutation_rejected", 1, true, {
273
+ property: prop,
274
+ });
275
+ this._error(
276
+ ERROR_PHASES.CONFIGURATION,
277
+ ERROR_CODES.SESSION_BINDING_MUTATED,
278
+ `'${prop}' is session-binding; remount the component to change it.`,
279
+ );
280
+ return false;
281
+ }
282
+
283
+ _validateConfiguration() {
284
+ const tokens = computeSandboxTokens(this._sandbox, this._removeSandboxTokens).split(" ");
285
+ if (!tokens.includes("allow-scripts")) {
286
+ return {
287
+ code: ERROR_CODES.ALLOW_SCRIPTS_STRIPPED,
288
+ message: "iframe sandbox MUST include 'allow-scripts'; embedding cannot run without it.",
289
+ };
290
+ }
291
+ if (tokens.includes("allow-top-navigation")) {
292
+ return {
293
+ code: ERROR_CODES.BLOCKED_SANDBOX_TOKEN,
294
+ message: "iframe sandbox MUST NOT include 'allow-top-navigation'; poses phishing risk.",
295
+ };
296
+ }
297
+ if (tokens.includes("allow-popups-to-escape-sandbox")) {
298
+ return {
299
+ code: ERROR_CODES.BLOCKED_SANDBOX_TOKEN,
300
+ message: "iframe sandbox MUST NOT include 'allow-popups-to-escape-sandbox'; poses security risk.",
301
+ };
302
+ }
303
+ if (!this._src) {
304
+ return {
305
+ code: ERROR_CODES.MISSING_SRC,
306
+ message: "iframe src MUST be provided; the embedding cannot start without a src.",
307
+ };
308
+ }
309
+ if (!this._iframeSrcOrigin) {
310
+ return {
311
+ code: ERROR_CODES.INVALID_SRC,
312
+ message: "iframe src MUST be a parseable URL.",
313
+ };
314
+ }
315
+ if (this._iframeSrcOrigin === window.location.origin) {
316
+ return {
317
+ code: ERROR_CODES.SAME_ORIGIN_SRC,
318
+ message: "iframe src origin MUST differ from the host document origin.",
319
+ };
320
+ }
321
+ return null;
322
+ }
323
+
324
+ /**
325
+ * Single error entry point. Reports exactly one uxerr — scoped to the open embedding_init span
326
+ * (which also counts embedding.init.error) when one is active, otherwise a standalone log —
327
+ * fires the public ERROR event, and logs. `code` doubles as the telemetry errorType. Pass
328
+ * `nextState` (e.g. STATES.CLOSED) to transition the instance. Pass `details.retryable = true`
329
+ * for transient errors where the embedding may recover (e.g. heartbeat timeout).
330
+ */
331
+ _error(phase, code, message, nextState, details) {
332
+ if (nextState) this._currentState = nextState;
333
+
334
+ if (!this._loadActivityStopped && this._loadActivity) {
335
+ this._errorLoadActivity(new Error(message), code);
336
+ }
337
+
338
+ // Error telemetry: phase/code/message only (PII-safe — no params.props).
339
+ this._instrumentation?.logError(new Error(message), { phase, code, instanceId: this._instanceId });
340
+
341
+ this.dispatchEvent(
342
+ // eslint-disable-next-line @sfdc-internal/lightning-base-components/no-custom-event-identifier-arguments
343
+ new CustomEvent(HostLwcEvent.ERROR, {
344
+ detail: { instanceId: this._instanceId, phase, code, message, retryable: details?.retryable ?? false },
345
+ bubbles: true,
346
+ composed: true,
347
+ }),
348
+ );
349
+
350
+ this._log(`${code} —`, message);
351
+ }
352
+
353
+ disconnectedCallback() {
354
+ // TODO: host-initiated teardown: send ui/notifications/shutdown + 500ms drain before close.
355
+ this._cancelHeartbeatDeadline();
356
+ if (this._heartbeatHandler) {
357
+ window.removeEventListener("message", this._heartbeatHandler);
358
+ this._heartbeatHandler = null;
359
+ }
360
+ if (this._mutationObserver) {
361
+ this._mutationObserver.disconnect();
362
+ this._mutationObserver = null;
363
+ }
364
+ this._restoreOriginalDispatchEvent();
365
+ // A pending microtask flush isn't cancellable, but `_closeHostBridge` nulls `_bridge` so the flush no-ops.
366
+ this._closeHostBridge();
367
+ this._channel = null;
368
+ this._container = null;
369
+ this._currentState = STATES.CLOSED;
370
+ this._frameShadow = null;
371
+ this._iframe = null;
372
+ this._iframeCreated = false;
373
+ this._initialLoadConsumed = false;
374
+ this._loadActivity = null;
375
+ this._loadActivityStopped = true;
376
+ this._portTransferred = false;
377
+ this._log("disconnectedCallback");
378
+ }
379
+
380
+ _closeHostBridge() {
381
+ if (this._bridge) {
382
+ // Disposing the bridge clears handlers, drops the subscription, and
383
+ // disposes the transport — which closes the port (idempotent).
384
+ this._bridge.dispose();
385
+ this._bridge = null;
386
+ this._hostPort = null;
387
+ return;
388
+ }
389
+ // No bridge yet (port created but never opened): close the raw port.
390
+ if (!this._hostPort) return;
391
+ try {
392
+ this._hostPort.close();
393
+ } catch (err) {
394
+ // ignore — already-closed ports throw on some runtimes.
395
+ this._instrumentation?.logError(err, { code: "PORT_CLOSE_FAILED", instanceId: this._instanceId });
396
+ }
397
+ this._hostPort = null;
398
+ }
399
+
400
+ _installHeartbeatListener() {
401
+ const handler = (event) => this._handleWindowMessage(event);
402
+ this._heartbeatHandler = handler;
403
+ window.addEventListener("message", handler);
404
+ }
405
+
406
+ // New src / new logical session: re-mint instanceId and rebuild the channel.
407
+ _resetSession() {
408
+ this._instanceId = crypto.randomUUID();
409
+ this._instrumentation?.incrementCounter("sfembedding.session.reset", 1, false);
410
+ this._resetChannel();
411
+ }
412
+
413
+ // Same logical session, new iframe document: rebuild channel state only; instanceId is
414
+ // stable so validateHeartbeat's expectedInstanceId check still passes.
415
+ _resetChannel() {
416
+ const prevState = this._currentState;
417
+ this._instrumentation?.incrementCounter("sfembedding.channel.reset", 1, false, { from: String(prevState) });
418
+ this._cancelHeartbeatDeadline();
419
+ this._closeHostBridge();
420
+ this._channel = new MessageChannel();
421
+ this._hostPort = this._channel.port2;
422
+ this._portTransferred = false;
423
+ this._initialLoadConsumed = false;
424
+ this._negotiatedProtocolVersion = null;
425
+ this._currentState = STATES.PRE_HEARTBEAT;
426
+ }
427
+
428
+ _handleIframeLoad() {
429
+ if (!this._iframeCreated) return;
430
+ // Both flags must be set to identify a real full-doc-nav reload. `_portTransferred` alone
431
+ // isn't enough: the embedded document can heartbeat before the iframe element's `load`
432
+ // task fires, so the very first `load` may arrive with the port already transferred.
433
+ if (this._initialLoadConsumed && this._portTransferred) {
434
+ this._log("iframe load: subsequent (full-doc nav) — rebuilding channel for instanceId", this._instanceId);
435
+ this._instrumentation?.incrementCounter("sfembedding.iframe.reload", 1, false);
436
+ this._resetChannel();
437
+ }
438
+ this._initialLoadConsumed = true;
439
+ // If the heartbeat won the race the deadline is already cancelled and the channel is READY;
440
+ // re-arming would re-open a closed deadline window for no reason.
441
+ if (!this._portTransferred) {
442
+ this._armHeartbeatDeadline();
443
+ }
444
+ }
445
+
446
+ // Advisory deadline; expiry fires an error event but never tears down state.
447
+ _armHeartbeatDeadline() {
448
+ this._cancelHeartbeatDeadline();
449
+ const ms = this._heartbeatTimeoutMs;
450
+ if (!Number.isFinite(ms)) return;
451
+ const timeout = this._safeTimeoutSignal(ms);
452
+ if (!timeout) return;
453
+
454
+ const controller = new AbortController();
455
+ this._heartbeatController = controller;
456
+
457
+ // Pin identity so a later src/session reset doesn't mis-attribute this timeout.
458
+ const snapshotSrc = this._src;
459
+ const snapshotInstanceId = this._instanceId;
460
+
461
+ const combined = this._anySignal([controller.signal, timeout]);
462
+ combined.addEventListener(
463
+ "abort",
464
+ () => {
465
+ if (controller.signal.aborted) return;
466
+ if (this._portTransferred) return;
467
+ if (this._src !== snapshotSrc) return;
468
+ if (this._instanceId !== snapshotInstanceId) return;
469
+ const message = `Embedded document did not send sf-embedding/ready heartbeat within ${ms}ms`;
470
+ this._instrumentation?.incrementCounter("sfembedding.heartbeat.timeout", 1, true);
471
+ this._error(ERROR_PHASES.BOOTSTRAP, ERROR_CODES.HEARTBEAT_TIMEOUT, message, undefined, {
472
+ retryable: true,
473
+ });
474
+ },
475
+ { once: true },
476
+ );
477
+ }
478
+
479
+ _cancelHeartbeatDeadline() {
480
+ if (this._heartbeatController) {
481
+ this._heartbeatController.abort();
482
+ this._heartbeatController = null;
483
+ }
484
+ }
485
+
486
+ // AbortSignal.timeout is used over setTimeout to satisfy @lwc/lwc/no-async-operation.
487
+ _anySignal(signals) {
488
+ const AS = AbortSignal;
489
+ if (typeof AS.any === "function") {
490
+ return AS.any(signals);
491
+ }
492
+ return signals[signals.length - 1];
493
+ }
494
+
495
+ _safeTimeoutSignal(ms) {
496
+ const AS = AbortSignal;
497
+ if (typeof AS.timeout === "function") {
498
+ return AS.timeout(ms);
499
+ }
500
+ return null;
501
+ }
502
+
503
+ _handleWindowMessage(event) {
504
+ const iframe = this._iframe;
505
+ const channel = this._channel;
506
+ if (!iframe || !channel) return;
507
+
508
+ const type = event.data?.type;
509
+ if (type === HEARTBEAT_TYPE) {
510
+ this._handleHeartbeat(event, iframe, channel);
511
+ } else if (type === SHUTDOWN_TYPE) {
512
+ this._handleShutdown(event, iframe);
513
+ }
514
+ }
515
+
516
+ _handleHeartbeat(event, iframe, channel) {
517
+ const result = validateHeartbeat({
518
+ data: event.data,
519
+ source: event.source,
520
+ origin: event.origin,
521
+ expectedSource: iframe.contentWindow,
522
+ expectedOrigin: this._iframeSrcOrigin,
523
+ expectedInstanceId: this._instanceId,
524
+ alreadyTransferred: this._portTransferred,
525
+ });
526
+
527
+ if (!result.ok) {
528
+ if (result.reason === ERROR_CODES.WRONG_SOURCE) {
529
+ this._countWrongSource("heartbeat", event.origin);
530
+ return;
531
+ }
532
+ const messages = {
533
+ WRONG_ORIGIN: `heartbeat origin '${event.origin}' does not match the expected iframe src origin '${this._iframeSrcOrigin ?? "<unset>"}'`,
534
+ PROTOCOL_VERSION_MISMATCH: `declared version '${result.protocolVersion ?? "<missing>"}' is not supported by host`,
535
+ INSTANCE_ID_MISMATCH: "heartbeat instanceId does not match the host-minted session instanceId",
536
+ DUPLICATE: "heartbeat received after port1 was already transferred",
537
+ };
538
+ const message = messages[result.reason] ?? `heartbeat rejected: ${result.reason}`;
539
+ this._instrumentation?.incrementCounter("sfembedding.heartbeat.rejected", 1, true, {
540
+ reason: result.reason,
541
+ });
542
+ this._error(ERROR_PHASES.BOOTSTRAP, result.reason, message);
543
+ return;
544
+ }
545
+
546
+ this._negotiatedProtocolVersion = result.protocolVersion;
547
+ this._log("heartbeat: negotiated protocol version", result.protocolVersion);
548
+
549
+ // Initialize the host bridge before transferring port1 — host end live before embedding can post back.
550
+ this._initializeHostBridge();
551
+
552
+ const envelope = buildBootstrapEnvelope({
553
+ instanceId: this._instanceId,
554
+ allowedOrigins: this._iframeSrcOrigin ? [this._iframeSrcOrigin] : [],
555
+ });
556
+
557
+ try {
558
+ iframe.contentWindow.postMessage(envelope, this._iframeSrcOrigin, [channel.port1]);
559
+ } catch (err) {
560
+ // Transfer threw (e.g. detached contentWindow); close the started port that has no peer.
561
+ this._instrumentation?.incrementCounter("sfembedding.bootstrap.transfer_failed", 1, true);
562
+ this._cancelHeartbeatDeadline();
563
+ this._closeHostBridge();
564
+ this._error(
565
+ ERROR_PHASES.BOOTSTRAP,
566
+ ERROR_CODES.TRANSFER_FAILED,
567
+ `<iframe> port transfer threw. Error: ${err instanceof Error ? err.message : String(err)}`,
568
+ STATES.CLOSED,
569
+ );
570
+ return;
571
+ }
572
+
573
+ this._portTransferred = true;
574
+ this._cancelHeartbeatDeadline();
575
+ this._instrumentation?.incrementCounter("sfembedding.heartbeat.received", 1, false, {
576
+ protocolVersion: result.protocolVersion,
577
+ });
578
+ this._currentState = STATES.POST_HEARTBEAT;
579
+ this._log("heartbeat: validated, port1 transferred");
580
+
581
+ // Phase A approximation: transition to READY now, before sending host-initialized.
582
+ // Once ui/select-version and ui/discover-capabilities land, READY will fire
583
+ // synchronously with the discover-capabilities response.
584
+ this._currentState = STATES.READY;
585
+
586
+ this._completeLoadActivity();
587
+
588
+ this.dispatchEvent(
589
+ // eslint-disable-next-line @sfdc-internal/lightning-base-components/no-custom-event-identifier-arguments
590
+ new CustomEvent(HostLwcEvent.READY, {
591
+ detail: { instanceId: this._instanceId },
592
+ bubbles: true,
593
+ composed: true,
594
+ }),
595
+ );
596
+
597
+ this._announceHostInitialized();
598
+ }
599
+
600
+ _initializeHostBridge() {
601
+ const port = this._hostPort;
602
+ if (!port) return;
603
+
604
+ // The bridge wraps the port in the shared transport and subscribes,
605
+ // which lazily starts the port (no manual addEventListener/start here).
606
+ // The bridge owns the ui/subscribe + ui/unsubscribe routing, the events
607
+ // subscribe/unsubscribe registry, and the resize-hint notification; it
608
+ // calls back into _composeUiState() for the snapshot returned on
609
+ // subscribe, _redispatchEventFromEmbedding() for inbound
610
+ // `ui/events/dispatch`, and _handleResizeHint() for `ui/notifications/resize`.
611
+ this._bridge = new HostBridge(
612
+ port,
613
+ () => this._composeUiState(),
614
+ (params) => this._redispatchEventFromEmbedding(params),
615
+ (params) => this._handleResizeHint(params),
616
+ (params) => this._handleSfEmbeddingError(params),
617
+ (...args) => this._log(...args),
618
+ this._instrumentation,
619
+ );
620
+ }
621
+
622
+ /** Pipe iframe errors to o11y (non-PII). */
623
+ _handleSfEmbeddingError(params) {
624
+ if (!params) return;
625
+ // Clamp untrusted iframe-supplied type/sdkMethod to bounded values before tagging (cardinality guard).
626
+ const KNOWN_ERROR_TYPES = [
627
+ "javascript-error",
628
+ "unhandled-rejection",
629
+ "rpc-failed",
630
+ "session-aborted",
631
+ "bootstrap-failed",
632
+ ];
633
+
634
+ const KNOWN_SDK_METHODS = [
635
+ "uncaught",
636
+ "bootstrapSession",
637
+ UI_STATE_SUBSCRIBE_METHOD,
638
+ UI_STATE_UNSUBSCRIBE_METHOD,
639
+ EVENTS_SUBSCRIBE_METHOD,
640
+ EVENTS_UNSUBSCRIBE_METHOD,
641
+ EVENTS_DISPATCH_METHOD,
642
+ RESIZE_METHOD,
643
+ ];
644
+
645
+ const boundedType = KNOWN_ERROR_TYPES.includes(params.type) ? params.type : "other";
646
+ const boundedSdkMethod = KNOWN_SDK_METHODS.includes(params.sdkMethod) ? params.sdkMethod : "other";
647
+
648
+ this._log("embedding-error", params.type, params.sdkMethod, params.filename ?? "");
649
+ const err = new Error(boundedType);
650
+ err.name = boundedType;
651
+ this._instrumentation?.logError(err, {
652
+ instanceId: this._instanceId,
653
+ sdkMethod: boundedSdkMethod,
654
+ type: boundedType,
655
+ isHostError: false,
656
+ });
657
+ this._instrumentation?.incrementCounter("embedding.sdk.error", 1, true, { type: boundedType });
658
+ }
659
+
660
+ /** Apply an inbound `ui/notifications/resize` hint directly to the iframe. */
661
+ _handleResizeHint(params) {
662
+ if (!params || !this._iframe) return;
663
+ // Both axes optional: an absent (or invalid) axis leaves that side of the iframe untouched.
664
+ const widthOk = Number.isFinite(params.width) && params.width >= 0;
665
+ const heightOk = Number.isFinite(params.height) && params.height >= 0;
666
+ if (!widthOk && !heightOk) {
667
+ this._instrumentation?.incrementCounter("sfembedding.resize.rejected", 1, true, {
668
+ reason: "invalid_dimensions",
669
+ });
670
+ return;
671
+ }
672
+ if (widthOk) this._iframe.style.width = `${params.width}px`;
673
+ if (heightOk) this._iframe.style.height = `${params.height}px`;
674
+ this._instrumentation?.incrementCounter("sfembedding.resize.applied", 1, false, {
675
+ width: widthOk ? "yes" : "no",
676
+ height: heightOk ? "yes" : "no",
677
+ });
678
+ }
679
+
680
+ /** Re-dispatch an inbound custom event from the embedding on the host element. */
681
+ _redispatchEventFromEmbedding(params) {
682
+ if (!params || typeof params.eventType !== "string") return;
683
+ // eslint-disable-next-line @sfdc-internal/lightning-base-components/no-custom-event-identifier-arguments
684
+ const event = new CustomEvent(params.eventType, {
685
+ detail: params.detail,
686
+ bubbles: params.bubbles ?? true,
687
+ composed: params.composed ?? true,
688
+ cancelable: params.cancelable ?? false,
689
+ });
690
+ // Mark *this specific event* as inbound so the override skips only its forward, not unrelated
691
+ // events a listener may dispatch synchronously during handling.
692
+ this._inboundDispatches.add(event);
693
+ this.dispatchEvent(event);
694
+ }
695
+
696
+ /** Resolve the real host DOM element (the shadow root's host); */
697
+ _getHostElement() {
698
+ const el = this.template?.host ?? this;
699
+ return el && typeof el.getAttribute === "function" ? el : undefined;
700
+ }
701
+
702
+ /** Compose a fresh UiState snapshot. */
703
+ _composeUiState() {
704
+ return composeUiState({
705
+ apiProps: this._props,
706
+ hostElement: this._getHostElement() ?? this,
707
+ });
708
+ }
709
+
710
+ /** Microtask-batched ui-state-changed flush; coalesces sync writes into one notification. */
711
+ _scheduleUiStateFlush() {
712
+ if (this._uiStateFlushScheduled) return;
713
+ this._uiStateFlushScheduled = true;
714
+ queueMicrotask(() => {
715
+ this._uiStateFlushScheduled = false;
716
+ this._flushUiStateChanged();
717
+ });
718
+ }
719
+
720
+ _flushUiStateChanged() {
721
+ const subscriptionId = this._bridge?.uiStateSubscriptionId;
722
+ if (!subscriptionId || !this._bridge) return;
723
+ const params = { subscriptionId, current: this._composeUiState() };
724
+ // Bridge stamps `_meta.traceId` centrally; never throws on transient failure.
725
+ this._bridge.notify(UI_STATE_CHANGED_METHOD, params);
726
+ }
727
+
728
+ /** Observe style + mirrored attrs + data-* on the host LWC; mutations schedule a flush. */
729
+ _installMutationObserver() {
730
+ if (this._mutationObserver) return;
731
+ const hostElement = this._getHostElement();
732
+ if (!hostElement) return;
733
+ const watched = new Set(["style", ...MIRRORED_ATTRIBUTES]);
734
+ const observer = new MutationObserver((mutations) => {
735
+ for (const m of mutations) {
736
+ if (m.type === "attributes" && m.attributeName) {
737
+ if (watched.has(m.attributeName) || m.attributeName.startsWith("data-")) {
738
+ if (m.attributeName === "title") {
739
+ // Only the raw-setAttribute path diverges here; the property setter already synced + stamped before reflecting.
740
+ const next = hostElement.getAttribute("title") || "embedding";
741
+ if (next !== this._title) {
742
+ this._title = next;
743
+ this._updateTitle();
744
+ }
745
+ }
746
+ this._scheduleUiStateFlush();
747
+ return;
748
+ }
749
+ }
750
+ }
751
+ });
752
+ try {
753
+ // No `attributeFilter`: it's an allowlist with no prefix support, and we need data-*.
754
+ observer.observe(hostElement, { attributes: true });
755
+ this._mutationObserver = observer;
756
+ } catch (err) {
757
+ this._log("MutationObserver attach failed —", err);
758
+ this._instrumentation?.logError(err, { code: "OBSERVER_ATTACH_FAILED", instanceId: this._instanceId });
759
+ }
760
+ }
761
+
762
+ /** Install dispatchEvent override on the real host element to forward all eligible CustomEvents. */
763
+ _installHostDispatchOverride() {
764
+ const hostElement = this._getHostElement();
765
+ if (!hostElement || this._originalDispatchEvent) return; // no host, or already patched
766
+
767
+ // Capture the original so teardown can restore it; host element is stable for the instance lifetime.
768
+ const original = hostElement.dispatchEvent.bind(hostElement);
769
+
770
+ try {
771
+ hostElement.dispatchEvent = (event) => {
772
+ if (
773
+ event instanceof CustomEvent &&
774
+ !RESERVED_EVENT_NAMES.has(event.type) &&
775
+ !event.type.startsWith("sf-embedding.") &&
776
+ !this._inboundDispatches.has(event) &&
777
+ this._bridge &&
778
+ this._bridge.hostEventSubscriberCount > 0
779
+ ) {
780
+ try {
781
+ this._bridge.notify(EVENTS_DISPATCH_METHOD, {
782
+ eventType: event.type,
783
+ detail: toCloneableDetail(event.detail),
784
+ });
785
+ } catch (err) {
786
+ this._log("forward to embedding failed —", err);
787
+ this._instrumentation?.logError(err, {
788
+ code: "EVENT_FORWARD_FAILED",
789
+ phase: ERROR_PHASES.EVENT_FORWARDING,
790
+ instanceId: this._instanceId,
791
+ });
792
+ }
793
+ }
794
+ return original(event);
795
+ };
796
+ this._forwardTarget = hostElement;
797
+ this._originalDispatchEvent = original;
798
+ } catch (err) {
799
+ this._log("dispatchEvent override install failed —", err);
800
+ this._instrumentation?.logError(err, {
801
+ code: "EVENT_FORWARD_FAILED",
802
+ phase: ERROR_PHASES.EVENT_FORWARDING,
803
+ instanceId: this._instanceId,
804
+ });
805
+ }
806
+ }
807
+
808
+ /** Restore the original dispatchEvent on the host element. */
809
+ _restoreOriginalDispatchEvent() {
810
+ if (!this._forwardTarget || !this._originalDispatchEvent) return;
811
+ this._forwardTarget.dispatchEvent = this._originalDispatchEvent;
812
+ this._originalDispatchEvent = null;
813
+ this._forwardTarget = null;
814
+ }
815
+
816
+ _announceHostInitialized() {
817
+ if (!this._bridge) return;
818
+
819
+ const params = {
820
+ instanceId: this._instanceId,
821
+ hostInfo: HOST_INFO,
822
+ };
823
+ // Transport.post never throws on transient failure; traceId is stamped
824
+ // by the bridge's client.
825
+ this._bridge.announceInitialized(params);
826
+ this._instrumentation?.incrementCounter("sfembedding.host_initialized.sent", 1, false);
827
+ this._log("host-initialized sent");
828
+ }
829
+
830
+ _handleShutdown(event, iframe) {
831
+ const result = validateShutdown({
832
+ data: event.data,
833
+ source: event.source,
834
+ origin: event.origin,
835
+ expectedSource: iframe.contentWindow,
836
+ expectedOrigin: this._iframeSrcOrigin,
837
+ });
838
+
839
+ if (!result.ok) {
840
+ if (result.reason === ERROR_CODES.WRONG_SOURCE) {
841
+ this._countWrongSource("shutdown", event.origin);
842
+ return;
843
+ }
844
+ this._log("shutdown: rejected —", result.reason);
845
+ this._instrumentation?.incrementCounter("sfembedding.shutdown.rejected", 1, true, {
846
+ reason: result.reason,
847
+ });
848
+ this._instrumentation?.logError(new Error("shutdown rejected"), {
849
+ code: "SHUTDOWN_REJECTED",
850
+ reason: result.reason,
851
+ instanceId: this._instanceId,
852
+ });
853
+ return;
854
+ }
855
+
856
+ this._log("shutdown received from embedding", result.reason);
857
+ this._instrumentation?.incrementCounter("sfembedding.shutdown.received", 1, false);
858
+ this._closeHostBridge();
859
+ this._error(
860
+ ERROR_PHASES.CONFIGURATION,
861
+ result.reason ?? ERROR_CODES.EMBEDDING_SHUTDOWN,
862
+ "embedding signalled shutdown.",
863
+ STATES.CLOSED,
864
+ );
865
+ }
866
+
867
+ _applySandbox() {
868
+ if (!this._iframe) return;
869
+ const tokens = computeSandboxTokens(this._sandbox, this._removeSandboxTokens);
870
+ this._iframe.setAttribute("sandbox", tokens);
871
+ this._log("sandbox", tokens);
872
+ }
873
+
874
+ _updateIframeSrc() {
875
+ if (!this._iframe) return;
876
+
877
+ this._resetSession();
878
+
879
+ this._iframe.removeAttribute("src");
880
+ if (this._src) {
881
+ try {
882
+ this._iframe.setAttribute(
883
+ "src",
884
+ appendHostMetaData(this._src, this._instanceId, window.location.origin),
885
+ );
886
+ } catch (err) {
887
+ this._error(
888
+ ERROR_PHASES.CONFIGURATION,
889
+ ERROR_CODES.INVALID_SRC,
890
+ `iframe src MUST be a parseable URL. Error: ${err instanceof Error ? err.message : String(err)}`,
891
+ STATES.CLOSED,
892
+ );
893
+ return;
894
+ }
895
+ }
896
+ this._log("updateIframeSrc", this._src ? "src set" : "blank");
897
+ }
898
+
899
+ _reflectHostTitle() {
900
+ const host = this._getHostElement();
901
+ if (host) {
902
+ host.setAttribute("title", this._title);
903
+ }
904
+ }
905
+
906
+ _updateTitle() {
907
+ if (this._iframe) {
908
+ this._iframe.setAttribute("title", this._title);
909
+ }
910
+ }
911
+
912
+ _startLoadActivity() {
913
+ if (this._loadActivity && !this._loadActivityStopped) {
914
+ this._loadActivityStopped = true;
915
+ this._instrumentation?.incrementCounter("sfembedding.load_activity.superseded", 1, false);
916
+ this._instrumentation?.stopLoadActivity(this._loadActivity, {
917
+ instanceId: this._instanceId,
918
+ widgetSrc: this._src,
919
+ hostAppOrigin: window.location.origin,
920
+ outcome: "superseded",
921
+ });
922
+ this._loadActivity = null;
923
+ }
924
+ this._activityStartedAt = Date.now();
925
+ this._loadActivityStopped = false;
926
+ this._loadActivity = this._instrumentation?.startLoadActivity() ?? null;
927
+ }
928
+
929
+ _completeLoadActivity() {
930
+ if (this._loadActivityStopped || !this._loadActivity) return;
931
+ this._loadActivityStopped = true;
932
+ const timeToReady = Date.now() - this._activityStartedAt;
933
+ this._instrumentation?.stopLoadActivity(this._loadActivity, {
934
+ instanceId: this._instanceId,
935
+ widgetSrc: this._src,
936
+ hostAppOrigin: window.location.origin,
937
+ outcome: "embedding_success",
938
+ timeToReady,
939
+ });
940
+ this._loadActivity = null;
941
+ this._instrumentation?.incrementCounter("embedding.init.count", 1, false);
942
+ }
943
+
944
+ _errorLoadActivity(err, code) {
945
+ if (this._loadActivityStopped || !this._loadActivity) return;
946
+ this._loadActivityStopped = true;
947
+ this._instrumentation?.errorLoadActivity(this._loadActivity, err, {
948
+ widgetSrc: this._src,
949
+ hostAppOrigin: window.location.origin,
950
+ message: err instanceof Error ? err.message : String(err),
951
+ isHostError: true,
952
+ instanceId: this._instanceId,
953
+ });
954
+ this._loadActivity = null;
955
+ // Use the bounded error code (not the free-text message) as the Argus tag to avoid
956
+ // unbounded cardinality — each unique string would mint a new time series.
957
+ this._instrumentation?.incrementCounter("embedding.init.error", 1, true, { reason: code });
958
+ }
959
+
960
+ // Records expected multi-MFE WRONG_SOURCE fan-out as a non-error counter.
961
+ _countWrongSource(kind, origin) {
962
+ this._instrumentation?.incrementCounter(`sfembedding.${kind}.wrong_source`, 1, false, { origin });
963
+ this._log(`${kind}: WRONG_SOURCE from`, origin);
964
+ }
965
+ }